diff --git a/README.md b/README.md
index ce3cf65b..04c221a4 100644
--- a/README.md
+++ b/README.md
@@ -142,6 +142,14 @@ counter; BG interception is before conversion. Unpayable overbid recovery remain
guarded. See the [selected policy and boundaries](docs/CHOAM_KULL_SOURCE_UPDATE.md).
This does not enable public expansion starts or certify full CHOAM.
+For local **Richese Nexus Betrayal** testing, use `node --import tsx tools/start-prototype.ts --profile richese-betrayal`
+on a fresh ready Basic/Advanced CHOAM/Richese-plus-classic lobby with the `choam`
+deck and Nexus only. The same exact-version/private-backup safeguards apply.
+The preview connects public self-cache purchase veto without payment and
+other-buyer cache/Black Market sale diversion with unchanged delivery and price.
+See the [cost, private timing, recovery evidence and remaining boundaries](docs/NEXUS_RICHESE_RULES.md#bounded-betrayal-auction-contract--30-september-2026).
+This does not enable public expansion starts or certify the full Nexus family.
+
Above the board, **Treachery draw pile** and **Spice draw pile** show live card
counts separately from hands, auction cards and discards. See
[draw-pile counts](docs/DRAW_PILES.md).
diff --git a/components/game-table.tsx b/components/game-table.tsx
index a2d13dc4..227b8eec 100644
--- a/components/game-table.tsx
+++ b/components/game-table.tsx
@@ -78,6 +78,7 @@ import { SukGraduatePanel } from './suk-graduate';
import { NullentropyBox, NullentropySearch } from './nullentropy-box';
import { SemutaReaction } from './semuta-reaction';
import { ChoamKull } from './choam-kull';
+import { NexusRicheseBetrayal } from './nexus-richese-betrayal';
import { OrnithopterMovement } from './ornithopter-movement';
import { DiscoveryOrnithopterMovement } from './discovery-flight-movement';
import { PlanetologistMovement } from './planetologist-movement';
@@ -301,8 +302,8 @@ export function GameTable({
);
const reactionBusy =
transportBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot;
- const busy = reactionBusy || g.automaticContinuationPending || !!g.semutaReaction || !!g.kullReaction;
- const kullOwnsControls = !!g.kullReaction || !!g.kullCounterEvent;
+ const busy = reactionBusy || g.automaticContinuationPending || !!g.semutaReaction || !!g.kullReaction || !!g.richeseBetrayalReaction;
+ const reactionOwnsControls = !!g.kullReaction || !!g.kullCounterEvent || !!g.richeseBetrayalReaction;
const canActivateKarama = (card: Card) =>
!g.karamaBlocked && canUseAsKaramaRole(g, me, card);
const traitorBattle = g.battle;
@@ -1008,6 +1009,8 @@ export function GameTable({
faction(g.players.find((p) => p.id === id)!.faction).name,
)
.join(' + ') || 'No winner'
+ : g.richeseBetrayalReaction
+ ? 'Richese auction acknowledgement'
: g.kullReaction
? 'CHOAM Karama response'
: g.semutaReaction
@@ -1035,6 +1038,14 @@ export function GameTable({
Preview rules and limits
)}
+ {g.richeseBetrayalPreview && (
+
+ Richese Nexus Betrayal development preview · public cache self-purchase
+ veto and ordinary other-buyer sale diversion only; not complete Richese
+ or certified Nexus rules.{' '}
+ Preview rules and limits
+
+ )}
{g.status !== 'lobby' && (
- {panel === 'hand' && kullOwnsControls ? (
+ {panel === 'hand' && reactionOwnsControls ? (
Your Treachery hand is empty.}>
{(card) => (
diff --git a/components/nexus-richese-betrayal.tsx b/components/nexus-richese-betrayal.tsx
new file mode 100644
index 00000000..31faf911
--- /dev/null
+++ b/components/nexus-richese-betrayal.tsx
@@ -0,0 +1,70 @@
+'use client';
+
+import type { Action, GameView } from '@/game/engine';
+import { CardInspector, CardRules } from './card-inspector';
+import { NexusCardFace } from './nexus-cards';
+import { Button } from './ui/button';
+
+export function NexusRicheseBetrayal({ game, act, busy }: {
+ game: GameView;
+ act: (action: Action) => void;
+ busy: boolean;
+}) {
+ const reaction = game.richeseBetrayalReaction;
+ if (!reaction) return null;
+ const target = game.players.find((player) => player.id === reaction.target);
+ const buyer = game.players.find((player) => player.id === reaction.buyer);
+ const canRespond = reaction.canPass && !reaction.hasPassed;
+ const canUse = canRespond && reaction.canUse && !reaction.blocked;
+ const disabled = busy || game.status !== 'playing';
+ // The receipt grants no card-face entitlement. Only the already-public cache
+ // face is shown; a Black Market face is never inferred from a seller's hand.
+ const card = reaction.source === 'cache' && game.richeseAuction?.source === 'cache'
+ ? game.richeseAuction.card
+ : null;
+
+ return (
+
+
Richese Nexus Betrayal · before payment
+
+ {buyer?.name ?? 'The buyer'} won {reaction.source === 'cache' ? 'the public cache lot' : 'the concealed Black Market lot'} for {reaction.price} spice.
+ {reaction.kind === 'purchase'
+ ? ` ${target?.name ?? 'Richese'} is buying its own cache card. Betrayal discards that card without payment or delivery.`
+ : ` Payment would go to ${target?.name ?? 'Richese'}. Betrayal sends the same payment to the Spice Bank instead; the buyer still receives the card.`}
+
+ {card &&
}
+ {reaction.source === 'blackMarket' &&
The card remains face down; this response does not grant inspection.
}
+
+ Every publicly possible unallied responder acknowledges this boundary, regardless of their secret Nexus identity.
+ Passing continues the original auction after all required acknowledgements; no payment or acquisition has happened yet.{' '}
+ Betrayal timing and preview limits
+
+ {!canRespond ? (
+
{reaction.hasPassed ? 'You have passed. Waiting for the remaining acknowledgements.' : 'Waiting for the required acknowledgements.'}
+ ) : (
+ <>
+ {reaction.blocked &&
{reaction.blocked}
}
+
+ {canUse && (
+
+ {game.nexusCards?.card === 'richese' && }
+
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index d40d4509..0aec6992 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -6,6 +6,24 @@ Expansion starts and full rules/publication acceptance remain gated.**
## Current checkpoint and work
+Five implementation lanes now connect the bounded
+[Richese Nexus Betrayal](NEXUS_RICHESE_RULES.md#bounded-betrayal-auction-contract--30-september-2026)
+preview: original public self-cache purchase veto without payment,
+and another buyer's cache/Black Market sale diversion to bank with
+unchanged price, contributions, physical delivery and earned Harkonnen
+bonus. Fresh `richese-betrayal` CLI entry requires paired CHOAM/Richese,
+classic remaining seats, the CHOAM deck and Nexus alone. Public possible
+responders acknowledge uniformly; only own canonical eligibility shows Use.
+Independent review repaired escrow double-counting and post-bonus
+exhausted-cache preflight. Types, lint, 5,953 offline tests, build and
+80 focused cases pass. Genuine local CLI/runtime and phone controls prove
+Basic veto, Advanced all-pass and Basic cache/Advanced Black Market
+diversion, exact custody and refresh. Local Wrangler HTTP is **51/55**:
+three POST 503s and one Advanced timeout, not green HTTP acceptance.
+Proxy-free CI and live deployment are separate. Normal hidden self-buys,
+private special acquisition, wider compositions and full-family completion
+remain open; no public start or live deployment is claimed.
+
The explicit `kull` development profile now connects printed Kull
Wahad interception in Basic/Advanced CHOAM-plus-classic rosters
with the physical CHOAM/Ix deck. Five implementation lanes delivered
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index 79a446ed..ed91ad4e 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,52 @@
# Dune implementation status
+## 30 September 2026 — bounded Richese Nexus Betrayal auction integration
+
+Five implementation lanes delivered pure source/invoice/custody policy,
+typed prepayment engine continuation, human controls and minimal legal bots,
+authenticated SQLite recovery, and source-bound internal guidance.
+Fresh `richese-betrayal` entry supports Basic/Advanced paired CHOAM/Richese
+plus classic seats, the physical CHOAM deck and Nexus alone.
+
+A public cache auction won by native Richese may be vetoed before payment:
+the exact intended card enters ordinary discard, nobody pays or acquires,
+and the original lot retires once. Another buyer's positive cache or
+concealed Black Market sale keeps the original price, authorized escrow
+split, physical delivery and earned Harkonnen bonus; only its recipient
+changes from Richese to bank. All-pass keeps the Nexus and settles the
+unchanged original purchase. Public held-Nexus presence determines neutral
+acknowledgement membership; actual identity affects only own private Use.
+Independent pending/completed cursor history binds saved source ownership
+and rejects orphaned frames before disclosure or SQL writes.
+
+Independent review found and repaired donor escrow double-counting and a
+Harkonnen bonus path that skipped exhausted-cache preflight. Regressions
+cover a seller funding its buyer with zero unpledged spice and rejection
+of an unfinishable closing bid before any receipt, payment or delivery.
+Existing wording/incidental assertions were removed rather than re-pinned.
+
+Verification: `npm run check` passes types, lint and **5,953 offline tests**;
+`npm run build` passes. Eight focused files pass **80 cases**, including
+both alternatives/all-pass, private timing parity, actual table controls,
+four legal bot profiles, JSON/production SQLite restart, competing Use/Pass,
+no-write rejections and later recyclable physical custody. Final independent
+economy and persistence re-reviews found no remaining scoped defect.
+
+Actual prototype CLI started four owned disposable rooms from version 5
+to setup 6. Genuine setup/bids created typed opportunities; a separate
+runtime smoke proves Basic/Advanced veto and cache/Black Market diversion.
+At 390 pixels, real controls prove Basic veto, Advanced all-pass, Basic
+cache diversion and refreshed Advanced Black Market diversion without an
+unknown-face inspector. Saved outcomes retain the original completed event,
+exact card custody and buyer/seller balances. No human games or seats were
+reset or modified.
+
+Current built-worker local HTTP is **51/55**, with three POST 503s and one
+Advanced-preview timeout; it is not green acceptance. Proxy-free container
+CI and publication are recorded separately after the push. Full Richese
+games, hidden normal-deck self-purchases, private special-Karama veto,
+broader modules and live NAS acceptance remain open; no public gate changes.
+
## 30 September 2026 — opt-in printed Kull Wahad integration
Five implementation lanes supplied validated activation frames,
diff --git a/docs/NEXUS_CARD_RULES.md b/docs/NEXUS_CARD_RULES.md
index eea575ff..4f2a4f93 100644
--- a/docs/NEXUS_CARD_RULES.md
+++ b/docs/NEXUS_CARD_RULES.md
@@ -61,7 +61,7 @@ The private holder offer contains CHOAM's name; it includes the hand count only
## Effect integration boundaries
-Later focused source contracts cover [Emperor strength, purchases and revival](NEXUS_EMPEROR_RULES.md), [Ixian purchased-card replacement](NEXUS_IXIAN_REPLACEMENT_RULES.md), and [Fremen worm and revival effects](NEXUS_FREMEN_RULES.md). These audits distinguish implemented work from remaining source/timing questions; the current runtime evidence is tracked in [implementation status](IMPLEMENTATION_STATUS.md).
+Later focused source contracts cover [Emperor strength, purchases and revival](NEXUS_EMPEROR_RULES.md), [Ixian purchased-card replacement](NEXUS_IXIAN_REPLACEMENT_RULES.md), [Fremen worm and revival effects](NEXUS_FREMEN_RULES.md), and [Richese's separate shipment and auction panels](NEXUS_RICHESE_RULES.md). These audits distinguish implemented work from remaining source/timing questions; the current runtime evidence is tracked in [implementation status](IMPLEMENTATION_STATUS.md). The bounded Richese Betrayal wave retains its own **Planned** verification rather than inheriting earlier auction or shipment evidence.
**Atreides is the first complete effect family to implement.** It has independent rulebook and physical-face evidence, and the engine already has private plan-element inspection. The Nexus effect needs its own cause and receipt rather than temporarily changing the inspecting faction. The second element must differ from the already inspected element. Preserve the original disclosed value if an allowed later battle edit occurs; follow the existing plan commitment rules instead of refreshing an old observation. The card specifies an element, not the entire hand or battle plan. A weapon/defense request uses actual played-card classification, including the published Reinforcements/Harass exclusions, rather than leaking a card merely because it occupies that slot.
@@ -79,7 +79,7 @@ Later focused source contracts cover [Emperor strength, purchases and revival](N
- No retrieved card or E3 FAQ gives a general rule that every Nexus effect is Karama-immune, or that playing one suspends all ordinary responses. Homeworld immunity on the preceding page is limited to Homeworld advantages/penalties. Audit cancellation of an underlying native advantage separately from cancellation of the Nexus card before enabling each family.
- Duration is explicit for several panels but not all. Ixian Betrayal's scope over multiple auction lots, the optional continuation after canceling native Prescience, and exact multi-party cancellation priority require the corresponding existing faction timing contract. Do not make an entire-phase cancellation merely from the word “advantage.”
- Emperor forced ally payment needs a precise invoice allocation when the ally has some funds and Emperor is short. Its own purchase-refund alternative also needs original payer contributions. Tleilaxu's combined leader/force revival requires an audit of partial selection and interaction with ordinary free rates; the card does not say that all five force revivals are free.
-- Richese's two-No-Field effect needs its own physical counter and immediate-reveal transaction; the normal single-marker action cannot represent it by adding private values. Ecaz's Duke override needs explicit restoration/end-turn handling across capture, death and Ghola records. These are material implementation tasks, not reasons to invent substitute effects.
+- Richese's two-No-Field effect has a bounded native Cunning shipment follow-up, but wider marker/module compositions remain separate. Its [bounded Betrayal auction contract](NEXUS_RICHESE_RULES.md#bounded-betrayal-auction-contract--30-september-2026) connects public self-cache veto and other-buyer sale diversion, not normal-deck hidden-family self-purchases or private special-Karama acquisition. Ecaz's Duke override needs explicit restoration/end-turn handling across capture, death and Ghola records. These are material implementation tasks, not reasons to invent substitute effects.
- A complete twelve-card reference/inventory and common lifecycle are substantial first work, but do not alone make all thirty-six panels playable. Keep the public module release gated until every face's supported timing, secrecy, custody, bots, UI, rejection, multiplayer CAS and saved continuation paths are verified. If development supports only Atreides' family first, other faces must remain accurately represented and explicitly unavailable in the audit mode; they must not silently act as blanks in a released module.
No user question was sent during this audit. No tournament rule, fan effect, public predeal reveal, extra Nexus identity or Discoveries prerequisite has been adopted.
@@ -102,3 +102,15 @@ precommitment would remove the printed after-loss choice. The sources do not
establish a generic Karama response to this borrowed Secret Ally effect, so
none is invented. The native Moritani alliance retention response remains a
separate power.
+
+## Richese Betrayal prepayment protocol
+
+The [printed Richese panel and bounded auction contract](NEXUS_RICHESE_RULES.md#bounded-betrayal-auction-contract--30-september-2026) distinguish two outcomes, not two costs. An unallied non-Richese holder spends the actual singleton Richese Nexus card while native Richese is seated. No Treachery slot/card or extra activation spice is spent.
+
+The public-cache self-purchase alternative discards the exact intended card **before** payment/delivery: nobody pays and no purchase bonus is fabricated. The other-buyer cache or Black Market sale alternative changes only the original positive invoice's recipient from Richese to bank: the original price/contributions, delivery and earned Harkonnen consequence remain once. Passing keeps the Nexus card; all required passes settle the unchanged original purchase once. Neither alternative refunds a completed sale or replays a raw purchase Action.
+
+To avoid identifying a secret card through timing, every unallied non-Richese seat with publicly held-Nexus presence acknowledges the same before-payment boundary. Required/pass membership derives from public roster/alliance/presence only. Only each privately eligible holder sees Use or its own blocked reason; irrelevant hidden identities do not change public event/auction fields. With no publicly possible responder, continuation is automatic. This is an **explicit application protocol**, not publisher timing authority or blanket Karama immunity.
+
+The reaction projects only public target/buyer/source/price/event plus own pass/use eligibility. Existing public cache inspection does not entitle anyone to the concealed Black Market face or to the Richese-family identity of a normal hidden lot; claims and printed catalog faces are not custody disclosures. Normal-deck self-purchases, special-Karama veto, unsupported counter/any-time overlays and broader modules remain outside this connected boundary. The private receipt owns exact source, invoice and continuation; independent pending/completed cursor evidence must reject missing/orphaned frames before read or SQL rather than auto-settling.
+
+The profile is explicit, fresh Basic/Advanced CHOAM/Richese plus classic seats, Nexus and the physical CHOAM deck only; it does not upgrade earlier games or open ordinary expansion starts. The dedicated internal `nexus-richese-betrayal` checklist records bounded engine, authenticated SQLite, bot, real controls and refreshed phone-browser evidence, with broader verification **Partial**. Shipment Cunning, ordinary auction, native special-Karama and a complete twelve-face catalog are not substitute verification. Full-family, combined-module and deployed acceptance remain open.
diff --git a/docs/NEXUS_RICHESE_RULES.md b/docs/NEXUS_RICHESE_RULES.md
index cd559094..873fd8e1 100644
--- a/docs/NEXUS_RICHESE_RULES.md
+++ b/docs/NEXUS_RICHESE_RULES.md
@@ -1,6 +1,6 @@
# Richese Nexus source contract
-Audited 10 September 2026. This separates the ordinary Secret Ally shipment from native Cunning's two-marker operation. It does not certify either implementation or open the Nexus release gate. See [common Nexus rules](NEXUS_CARD_RULES.md), [No-Field custody](RICHESE_NO_FIELD_RULES.md), and [allied No-Field distinctions](RICHESE_ALLIED_SHIPMENT_AUDIT.md).
+Audited 10 September 2026, with a bounded Betrayal implementation contract added 30 September. This separates the ordinary Secret Ally shipment, native Cunning's two-marker operation and Betrayal's two auction outcomes. It does not certify full-family implementation or open the Nexus release gate. See [common Nexus rules](NEXUS_CARD_RULES.md), [auction custody and source boundaries](RICHESE_AUCTION_RULES.md), [No-Field custody](RICHESE_NO_FIELD_RULES.md), and [allied No-Field distinctions](RICHESE_ALLIED_SHIPMENT_AUDIT.md).
## Printed effects and source status
@@ -8,6 +8,8 @@ The Richese panel was freshly read from the [original photographed GF9 cards](ht
Common eligibility requires **Richese absent**, not Guild absent, for Secret Ally. A native Guild player may hold it. A holder cannot retain a Nexus card when entering an alliance, and play consumes the card. E3 contains no Richese-specific Nexus FAQ. [GF9 E3 p.11 and p.16](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=11)
+The photographed Betrayal panel has two alternatives: stop Richese buying a Richese Treachery Card by discarding that intended card without payment, **or** redirect the payment for a card Richese sells to the Spice Bank. Both spend the same physical Richese Nexus card; they are not two uses or a generic cancellation of Richese income. The panel's source is the [original GF9 component photograph](https://boardgamegeek.com/image/7767032/dune-ecaz-and-moritani), semantically transcribed in `game/nexus-card-reference.ts`. Auction origin, payment routing and still-earned purchase consequences compose with [GF9 E2 pp.4–6 and 10–12](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf), as audited in the auction contract; no tournament amendment supplies authority.
+
Official indexed excerpts, the original component photograph, and cached E3 publisher-authored text were inspected. Direct retrieval of the official base/November FAQ PDFs returned 403. The older local base/E2 cache paths named by historical documents are no longer present in this workspace; those historical claims were checked against fresh publisher excerpts where used below.
## Secret Ally: ordinary shipment composition
@@ -66,7 +68,7 @@ The connected path is deliberately narrower than every possible E2 composition:
The card is discarded at declaration. The native No-Field Karama cancellation precedes settlement and preserves the two tokens, reserves, spice and ordinary shipment allowance but not the card. Advanced Guild special prevention also leaves the physical shipment unpaid and unplaced while consuming the attempted shipment allowance and the card. Otherwise the pair is one arrival, costs the ordinary one-marker price, immediately reveals one chosen denomination and places up to that value of actual normal forces from current reserves in the destination. Only the other token remains concealed, publicly counting as one force; it draws from then-current reserves on its later native reveal. The immediate group plus the concealed marker count as actual presence for subsequent territory-entry and board logic, not two independent shipments.
-An owner-only control distinguishes ordinary No-Field, Smuggler and Nexus pair choices. All four bot profiles have a minimal legal pair candidate. The pending frame binds both physical identities, owner, token event, destination and one-marker price through JSON and in-memory SQLite CAS recovery. Public logs disclose the immediate faceup value/actual force count after settlement; the rival projection contains no concealed token identity or owner-only offer. Signatures detect partial saved-frame corruption, not arbitrary save forgery. Combined modules, allied No-Fields, high-Richese additional-force effects, Betrayal, later faceup display mechanics and full Nexus acceptance remain open.
+An owner-only control distinguishes ordinary No-Field, Smuggler and Nexus pair choices. All four bot profiles have a minimal legal pair candidate. The pending frame binds both physical identities, owner, token event, destination and one-marker price through JSON and in-memory SQLite CAS recovery. Public logs disclose the immediate faceup value/actual force count after settlement; the rival projection contains no concealed token identity or owner-only offer. Signatures detect partial saved-frame corruption, not arbitrary save forgery. Combined modules, allied No-Fields, high-Richese additional-force effects, later faceup display mechanics and full Nexus acceptance remain open. Betrayal now has its separate bounded auction contract below; the Cunning evidence does not verify that path.
## Existing integration points and verification
@@ -75,3 +77,50 @@ An owner-only control distinguishes ordinary No-Field, Smuggler and Nexus pair c
`game/homeworld-shipment.ts` and the Guild Homeworld adapter own non-Arrakis routes; they should not be bypassed with a forged static board destination. `game/richese-no-field.ts` owns physical markers and should not be initialized for absent-faction Secret Ally.
Required focused evidence: 1/5 actual-force shipments with unchanged price; normal/elite mixtures; Guild present/absent/native-holder and active bank-rate payment; Fremen retained geography/free price; native Homeworld source custody; occupancy/storm rejection before costs; Guild allowance/prevention; real BG/Terror/Ambassador continuations using actual counts; all-seat projections; and persisted pending/complete/stopped histories with concurrent and rejected-action immutability. Complete Cunning and broader module acceptance remain separate work.
+
+## Bounded Betrayal auction contract — 30 September 2026
+
+### Entry and actual cost
+
+The explicit local `richese-betrayal` profile is for genuine fresh Basic or Advanced games with CHOAM and Richese, two to six seats, and otherwise classic factions. It selects the physical CHOAM Treachery deck and Nexus module, with no other optional modules; Ixians, Tleilaxu, Ecaz, Moritani, Semuta and Kull previews are outside this boundary. `initializeRicheseBetrayalGameForAudit(state: Game): Game` is the engine admission seam. Existing games and other preview profiles do not silently gain this behavior. Ordinary public expansion starts and full Nexus/Richese certification remain closed.
+
+Use requires an **unallied** holder whose actual native faction is not Richese, native Richese seated, and the one physical Richese Nexus card in that holder's separate custody. Spending it moves that singleton to Nexus discard once. No ordinary Treachery card, Karama, hand slot or extra spice is the activation cost. Current own-card promises and committed-resource legality still apply before acceptance; merely possessing the face is not sufficient. Passing keeps the Nexus card and spends nothing.
+
+### Prepayment veto versus recipient override
+
+| Alternative | Connected trigger | Physical card and spice result |
+| --- | --- | --- |
+| Purchase veto | Richese wins its own **public cache** lot through Once Around or Silent bidding, before debit or acquisition | Move the exact selected cache card to ordinary Treachery discard, not Richese's hand, cache return or out-of-game removal. Do not debit the buyer or ally, and do not pay Richese, Emperor or bank. No purchase was made, so no Harkonnen purchase bonus is created. Retire the original lot/round continuation once. |
+| Sale diversion | Another buyer wins a cache lot or concealed Advanced Black Market lot (normal, Once Around or Silent), with a positive payment otherwise due to Richese | Keep the original winner, price, own/authorized-ally contributions and physical delivery. Route that same payment to the bank instead of Richese; Richese receives zero sale income. The buyer is charged only once, not a second bank fee. Existing earned buyer consequences, including the Harkonnen bonus when applicable, continue once. |
+
+Richese buying its own cache normally pays Emperor/bank. That invoice is the purchase-veto alternative, **not** a sale payment owed to Richese. A zero result/free cache keep or unsold Black Market retention is not a positive sale. Existing exhausted-cache/ordering guards, positive Black Market seller-bid guards and special-acquisition guards remain; this panel does not settle their source questions.
+
+Normal-deck Richese self-purchases can conceal whether the physical card belongs to the Richese family. They are **not connected** by this first boundary. The private advanced special-Karama cache acquisition has a separate activating card and once-per-game ordering and is also **not a connected veto trigger**. Native statements about whether Karama can cancel that special power do not establish whether Nexus Betrayal applies to it. Do not certify either producer by borrowing an unrelated native exception.
+
+The interruption belongs inside `settleRicheseLot`, after native responses and any entitled Atreides peek have finished and the sold quote is valid, but before `payWithAlly`, source removal or hand delivery. Source/recipient facts come from that original quote, not a client-selected target, card, amount or recipient. Unsupported counter overlays or any-time effects that the receipt cannot resume must be prevented before entry, not suppressed after money moves. A diversion is a recipient override, not a refund, re-purchase or replay of bidding.
+
+The current preflight requires a playing Bidding-stage Richese lot with no ordinary auction/sale, response, decision, phase opening, active Truthtrance, Karama, Nullentropy/discard, Kull/CHOAM conversion or market, Ixian allied replacement, Richese gift/purchase-income/Technology, or pending Nexus traitor exchange overlay. The original quote proves exact budget, representable sale/bonus continuation, exhausted-cache guards and binding shipment-completion feasibility before the pause. While pending, only the exact reaction choices and bot advancement are admitted; another any-time effect cannot mutate the reserved transaction. Spending the holder's Nexus must preserve that holder's own binding shipment promises. An involuntary purchase veto does not assume the target acquired the card or spent the price; opponent promises made impossible by that prevention use existing native reconciliation/release. These are bounded continuation guards, not blanket publisher immunity. The cache veto is a direct ordinary discard; the profile does not compose it with Semuta.
+
+### Neutral application protocol and privacy
+
+The pause is uniform for all **publicly possible** responders: other-faction unallied seats with publicly visible held-Nexus presence. Required/pass IDs depend only on roster, alliances and that presence, never on a secret held identity. If no seat is publicly possible, the original purchase automatically continues. Otherwise each required seat may pass, including a holder of an irrelevant Nexus card. Only the privately eligible Richese-card holder sees Use. Once all required seats pass, settle the original quoted purchase once; an accepted Use commits its one alternative and closes that opportunity.
+
+This neutral acknowledgement is an explicit application protocol to preserve concealed custody, **not** printed publisher timing or a general ruling about simultaneous Nexus/Karama priority. A holder-only pause would reveal a secret card. Replacing a relevant hidden card with an irrelevant hidden card must not change the public pause, event or auction terms.
+
+`GameView.richeseBetrayalPreview` identifies the opt-in profile. `GameView.richeseBetrayalReaction` is null or `{ event, kind, target, buyer, source, price, canPass, hasPassed, canUse, blocked }`, with `kind: 'purchase' | 'sale'` and `source: 'cache' | 'blackMarket'`. Every seat sees the same public event, kind, Richese target, original buyer, source and price. Purchase requires cache and buyer equal to target; sale requires another buyer. `canPass` and `hasPassed` concern only the viewer's public acknowledgement; `canUse` and `blocked` concern only that viewer's actual private eligibility.
+
+The reaction contains no target physical card, hidden Black Market face, payer spice, ally balances, opponent hand, private Nexus identity, raw source frame, signature or deck order. Public cache faces remain available through existing entitled inspectors. A Black Market claim is not the unknown face; the new reaction grants no new inspection permission. Existing Atreides or owner entitlement remains separate. Public Nexus face reference is a catalog, not proof that anyone holds that face.
+
+Clients submit exactly `{ type: 'richeseBetrayalPass', event }` or `{ type: 'richeseBetrayalUse', event }`. The server checks actor/event and exact keys and supplies the original target/card/price/recipient from private state. Human controls and all four minimal legal bot policies consume the same projected eligibility; no consumer guesses Use from physical custody alone. Pending acknowledgement replaces underlying auction actions rather than letting them charge or advance the lot early.
+
+### Saved continuation and verification status
+
+A typed private frame/receipt binds the original round, lot, physical card, Richese owner, buyer, price/funding, source custody and continuation ownership. Independent pending/completed cursor evidence prevents deletion or orphaning of that frame from automatically settling a purchase. Reconnect resumes the exact opportunity, recorded passes and committed outcome without rerolling, replaying a raw Action, rolling back a full Game or echoing payment. Intrinsic committed history must remain valid after the Nexus or purchased/discarded Treachery card subsequently moves; it must not require an old card to stay in discard forever.
+
+The implemented state names are `Game.pendingRicheseBetrayal` for `{ receipt, lot, quote, signature }` and independent `Game.richeseBetrayal` for `{ sequence, current, completed }`. The receipt carries event/turn/sequence, original round/lot, private invoice, public-required IDs, recorded passes, `pending | passed | used` stage, actor and Nexus-discard index. Its invoice includes the original recipient, price, own/ally contribution split, donor and private physical card. The source signature binds live original custody/funding/continuation; historical completed receipts keep intrinsic evidence rather than asserting present discard location. These deterministic signatures detect inconsistency, not arbitrary save forgery, and are never part of the player reaction projection.
+
+Implementation responsibilities are `game/nexus-richese-betrayal.ts` for pure invoice/source/eligibility and receipt invariants; `game/engine.ts` and `game/auction-continuation-quote.ts` for settlement, projection and finalizer fences; `components/nexus-richese-betrayal.tsx` and `game/bot-nexus-richese-betrayal.ts` for real private Use/public pass/wait consumers. The internal player topic is `nexus-richese-betrayal` in `game/reference.ts`; player navigation remains internal-only.
+
+**Verification: Partial, with the bounded runtime verified locally.** TypeScript, lint, all 5,953 offline tests and the app build pass; eight focused files pass 80 cases. The five dedicated suites cover purchase veto/all-pass, cache and concealed Black Market diversion, exact Nexus/card/spice/escrow custody, native Harkonnen bonus and cancellation, private identity parity, rejected-action immutability, all four legal bot profiles, real table controls and authenticated SQLite restart/concurrency/orphan rejection. Independent economy and persistence reviews exposed an erroneous second donor debit and a post-bonus exhausted-cache deadlock; both are fixed with regressions. Preflight consumes already pledged escrow and validates the deterministic next boundary before reserving an opportunity.
+
+Actual `richese-betrayal` CLI entry, source runtime settlement and 390-pixel browser controls were exercised in disposable QA rooms: Basic purchase Use discards without debit/delivery; Advanced purchase Pass preserves the Nexus and performs the original purchase; Basic cache and Advanced Black Market Use send the six-spice payment to bank once while delivering the exact card. Refresh retains each completed event and physical custody; the Black Market holder sees no unknown face inspector. No existing human game or seat was changed. Local Wrangler HTTP passes 51/55: three POST 503s and one Advanced-preview timeout prevent a green local HTTP claim. Proxy-free container CI and live NAS evidence are separate; no deployed acceptance or full Richese/Nexus completion is claimed.
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index 8267eb95..42c46ca8 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -35,6 +35,7 @@ Updated 30 September 2026. This is the authoritative navigation index for existi
| 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) |
| 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) |
+| 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) |
| CHOAM Secret Ally | Absent-faction Collection trade spends one actual Worthless card and the Nexus for two bank spice. It is separate from native Cunning and Auditor; the battle inspection alternative remains missing. | [Prototype contract](NEXUS_CHOAM_SECRET_ALLY.md) |
| Sapho movement | At a clean boundary, first reorders only remaining unstarted turns; completed turns stay completed. Advanced Guild must have finished or be absent. Last retains existing protection after Guild. | [Existing source contract](JUICE_OF_SAPHO_SOURCE_UPDATE.md#movement-firstlast), [runtime](JUICE_OF_SAPHO_RUNTIME.md) |
| Basic CHOAM / Leader Skills | Compose existing ordinary skills with native sales and own-leader Ghola revival. Finish the private optional replacement before the exact suspended sale resumes. The free revival remains completed if the resumed sale is canceled. Basic uses five ordinary leaders and 35 Treachery Cards; Auditor remains Advanced and ineligible for skills. | [Integration and source contracts](CHOAM_LEADER_SKILLS.md) |
diff --git a/game/auction-continuation-quote.ts b/game/auction-continuation-quote.ts
index e9682d65..a23f23a6 100644
--- a/game/auction-continuation-quote.ts
+++ b/game/auction-continuation-quote.ts
@@ -263,6 +263,9 @@ function calculate(
sale = { ...s, legacy: false };
cardId = lot.cardId;
}
+ requireAuction(sale.recipient === undefined ||
+ (sale.recipient === 'bank' && sale.origin !== 'normal' && sale.seller !== sale.winner && sale.amount > 0),
+ 'A bank recipient override requires a positive Richese sale to another buyer.');
requireAuction(
count(sale.amount) && typeof sale.free === 'boolean',
'The paid auction amount or free flag is invalid.',
@@ -342,17 +345,19 @@ function calculate(
): AuctionContinuationQuote => ({ sale, steps, next });
if (stage === 'sale') {
if (sale.origin !== 'normal' && sale.seller !== winner.id) {
- const seller = seated(sale.seller!)!;
- requireAuction(
- count(seller.spice) && count(seller.spice + sale.amount),
- 'The seller credit would overflow its current balance.',
- );
- steps.push({
- kind: 'sellerCredit',
- player: seller.id,
- amount: sale.amount,
- balance: seller.spice + sale.amount,
- });
+ if (sale.recipient !== 'bank') {
+ const seller = seated(sale.seller!)!;
+ requireAuction(
+ count(seller.spice) && count(seller.spice + sale.amount),
+ 'The seller credit would overflow its current balance.',
+ );
+ steps.push({
+ kind: 'sellerCredit',
+ player: seller.id,
+ amount: sale.amount,
+ balance: seller.spice + sale.amount,
+ });
+ }
} else {
const emperor = input.players.find((p) => p.faction === 'emperor');
if (!sale.free && emperor && emperor.id !== winner.id)
diff --git a/game/bot-nexus-richese-betrayal.ts b/game/bot-nexus-richese-betrayal.ts
new file mode 100644
index 00000000..6d6de40b
--- /dev/null
+++ b/game/bot-nexus-richese-betrayal.ts
@@ -0,0 +1,15 @@
+import type { Action, GameView } from './engine';
+
+/** All profiles obey the same seat-private legality, including promised cards
+ * and committed budgets. Held-card hints cannot override the projected offer. */
+export function botNexusRicheseBetrayalActions(game: GameView): Action[] {
+ const reaction = game.richeseBetrayalReaction;
+ if (game.status !== 'playing' || !reaction?.event ||
+ !reaction.canPass || reaction.hasPassed) return [];
+ const own = game.players.find((player) => player.id === game.me);
+ if (!(own?.bot ?? own?.autopilot)) return [];
+ return [{
+ type: reaction.canUse && !reaction.blocked ? 'richeseBetrayalUse' : 'richeseBetrayalPass',
+ event: reaction.event,
+ }];
+}
diff --git a/game/bots.ts b/game/bots.ts
index 3631aae3..9781cc89 100644
--- a/game/bots.ts
+++ b/game/bots.ts
@@ -103,6 +103,7 @@ import { presenceAt } from './force-presence';
import { botArrivalBlock, botWormRideArrivalBlock } from './bot-arrival';
import { botSemutaActions } from './bot-semuta';
import { botChoamKullActions, botChoamKullCounterActions } from './bot-choam-kull';
+import { botNexusRicheseBetrayalActions } from './bot-nexus-richese-betrayal';
import { validateCohortSelection, type OrnithopterMode } from './ornithopter';
import { fighterCount, isAdvisor } from './advisors';
import { casualtyOptions, maxCombatDial, maxCombatSupport, type CombatForces } from './combat';
@@ -3826,6 +3827,7 @@ function standaloneGholaAction(g: GameView, ordinary: Action[]): Action | null {
/** Obligations apply across policy branches, including choosing to move first. */
export function botActions(g: GameView): Action[] {
+ if (g.richeseBetrayalReaction) return botNexusRicheseBetrayalActions(g);
if (g.kullReaction) return botChoamKullActions(g);
if (g.kullCounterEvent)
return botChoamKullCounterActions(g);
diff --git a/game/engine.ts b/game/engine.ts
index 6340d4f5..b46a8c40 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -414,6 +414,7 @@ import {
quoteRicheseSettlement,
requireRicheseDeclarationCache,
RicheseSettlementError,
+ type RicheseSettlementQuote,
} from './richese-settlement';
import {
validateAmbassadors,
@@ -465,6 +466,7 @@ import { reserveExtortion, collectExtortion, answerExtortion, validateExtortionS
import { STORM_START_SECTOR, PLAYER_CIRCLE_SECTORS } from './player-positions';
import { cashInCards } from './choam-karama';
import { kullBlocksKarama, activeKullRestrictions, validateKullPhaseRestrictions, validateKullAttemptStamp, kullNativeCostCards, kullCounterCards, type KullPhaseRestriction } from './choam-kull';
+import { quoteRicheseBetrayalInvoice, requiredRicheseBetrayalResponders, richeseBetrayalEligibility, richeseBetrayalEvent, createRicheseBetrayalReceipt, validateRicheseBetrayalCursor, type RicheseBetrayalCursor, type RicheseBetrayalReceipt } from './nexus-richese-betrayal';
import { saleOptions, quoteSale, type ChoamMarket } from './choam-market';
import { highKaitainDiscardsAvailable, quoteKaitainDiscards, homeworldWorthlessSaleBlock } from './homeworld-card-economy';
import { biddingEndError, biddingEndQuiet, biddingEndPubliclyEmpty, type BiddingEnd } from './bidding-end';
@@ -1079,6 +1081,15 @@ type RicheseAllyOffer = {
payer: string;
};
export type Game = {
+ /** Explicit bounded Nexus/Richese auction profile; never inferred for old saves. */
+ richeseBetrayalPreview?: boolean;
+ richeseBetrayal?: RicheseBetrayalCursor;
+ pendingRicheseBetrayal?: {
+ receipt: RicheseBetrayalReceipt;
+ lot: RicheseAuction;
+ quote: Extract;
+ signature: string;
+ } | null;
/** Explicit development profile; never enabled by a public start or player action. */
kullPreview?: boolean;
kullSequence?: number;
@@ -1445,6 +1456,8 @@ export type Game = {
free: boolean;
origin: 'normal' | 'cache' | 'blackMarket';
seller: string | null;
+ /** Original seller remains the source owner when the invoice is sent to bank. */
+ recipient?: 'bank';
} | null;
pendingAmbassador?: {
revivalEvent?: string;
@@ -8088,6 +8101,19 @@ export function initializePairedNexusGameForAudit(state: Game): Game {
return initializeSetupGameForAudit(state, false, true, false, false, false, false, true);
}
+/** Genuine opt-in auction source interruption; ordinary/public starts stay gated. */
+export function initializeRicheseBetrayalGameForAudit(state: Game): Game {
+ requireRule(!state.richeseBetrayalPreview && !state.richeseBetrayal && !state.pendingRicheseBetrayal &&
+ richeseBetrayalModeSupported(state) &&
+ state.nexusCards?.cards === null && state.nexusCards.phase === null,
+ 'Richese Betrayal needs a fresh CHOAM/Richese lobby, the CHOAM deck and only Nexus Cards.');
+ const g = initializeSetupGameForAudit(state, false, true, false, false, false, false, true);
+ g.richeseBetrayalPreview = true;
+ g.richeseBetrayal = {sequence:0,current:null,completed:[]};
+ g.pendingRicheseBetrayal = null;
+ return g;
+}
+
/** Offline-only all-expansion Nexus composition, optionally with Homeworlds; not a public start gate. */
export function initializeCombinedNexusGameForAudit(state: Game, homeworlds = false): Game {
requireRule(state.expansions.length === 3 &&
@@ -9379,6 +9405,188 @@ function finishRicheseLot(g: Game) {
nextPhase(g);
}
}
+function richeseBetrayalModeSupported(g: Game): boolean {
+ return g.expansions.length === 1 && g.expansions[0] === 'choam' &&
+ g.players.length >= 2 && g.players.length <= 6 &&
+ g.players.some(p => p.faction === 'richese') && g.players.some(p => p.faction === 'choam') &&
+ g.players.every(p => RICHESE_CUNNING_ROSTER.has(p.faction)) &&
+ !!g.nexusCards && !g.homeworlds && !g.leaderSkills && !g.discoveryEnabled &&
+ !g.ecazTreachery && !g.sandtrout && !g.techTokens && !g.strongholdCards &&
+ !g.semutaPreview && !g.kullPreview;
+}
+function richeseBetrayalResponders(g: Game): string[] {
+ return requiredRicheseBetrayalResponders(g.players.map(p => ({
+ id:p.id,faction:p.faction,ally:p.ally,nexusHeld:!!g.nexusCards?.cards?.hands[p.id],
+ })));
+}
+function richeseBetrayalSourceSignature(g: Game, frame: NonNullable): string {
+ return JSON.stringify({
+ event:frame.receipt.event,lot:frame.lot,quote:frame.quote,turn:g.turn,phase:g.phase,status:g.status,
+ round:g.richeseBidding,auction:g.richeseAuction,order:g.order,positions:normalizedPlayerPositions(g),
+ funding:g.richeseFunding,aid:g.aid,cache:g.richeseCache,offered:g.richeseOfferedCard,
+ players:g.players.map(p => ({id:p.id,faction:p.faction,ally:p.ally,hand:p.hand,spice:p.spice})),
+ active:g.active,decision:g.decision,response:g.response,phaseOpening:g.phaseOpening,
+ peek:g.richesePeekKnown,claim:g.richeseClaim,
+ });
+}
+function richeseBetrayalInvoice(g: Game, quote: Extract) {
+ const lot = g.richeseAuction!;
+ return nexusRule(() => quoteRicheseBetrayalInvoice({
+ players:g.players,lot,quote,donor:quote.allyPayment ? getPlayer(g,quote.winner).ally : null,
+ originalRecipient:quote.winner === lot.owner ? byFaction(g,'emperor')?.id ?? null : lot.owner,
+ sourceCards:lot.source === 'cache' ? g.richeseCache! : getPlayer(g,lot.owner).hand,
+ physicalCards:physicalTreacheryCards(g),
+ }));
+}
+function richeseBetrayalSourcePreflight(g: Game, quote: Extract): void {
+ requireRule(richeseBetrayalModeSupported(g) && g.status === 'playing' && g.phase === 3 &&
+ g.richeseBidding?.stage === 'lot' && g.richeseBidding.turn === g.turn &&
+ !g.auction && !g.currentAuctionSale && !g.response && !g.decision && !g.phaseOpening &&
+ !g.truthtrance && !g.pendingKarama && !g.pendingNullentropy && !g.pendingTreacheryDiscard &&
+ !g.pendingKull && !g.pendingChoamWorthless && !g.choamMarket && !g.pendingIxAlly &&
+ !g.pendingRicheseGift && !g.pendingRichesePurchaseIncome && !g.pendingIxRicheseTechnology &&
+ !g.biddingEnd && !g.bureaucratPayments?.pending &&
+ !pendingNexusTraitors(g),
+ 'Richese Betrayal requires a clean ordinary Richese lot before payment, without another effect overlay.');
+ const lot = g.richeseAuction!, donor = quote.allyPayment ? getPlayer(g,quote.winner).ally : null;
+ const players = g.players.map(p => ({
+ ...p,
+ spice:p.spice - (p.id === quote.winner ? quote.amount - quote.allyPayment : 0),
+ hand:[...(lot.source === 'blackMarket' && p.id === lot.owner
+ ? p.hand.filter(c => c.id !== quote.card.id) : p.hand),...(p.id === quote.winner ? [quote.card] : [])],
+ }));
+ const projected:Game = {
+ ...g,players,richeseCache:lot.source === 'cache' ? g.richeseCache!.filter(c => c.id !== quote.card.id) : g.richeseCache,
+ aid:donor ? {...g.aid,[donor]:{...g.aid[donor],amount:g.aid[donor].amount - quote.allyPayment}} : g.aid,
+ currentAuctionSale:{winner:quote.winner,amount:quote.amount,free:false,origin:lot.source,seller:lot.owner},
+ };
+ // Both original and redirected settlement must retire this same lot. This
+ // includes native exhausted-cache and Harkonnen continuation fences.
+ currentAuctionContinuationQuote(projected,{kind:'sale',free:false});
+ currentAuctionContinuationQuote(projected,{kind:'bonus'});
+ currentAuctionContinuationQuote(projected,{kind:'next'});
+ for (const p of players) {
+ const promises = liveShipmentPromises(g.shipmentPromises ?? [],p.id,g.turn);
+ requireRule(!promises.length || !!findShipmentCompletion(projected,p,promises),
+ 'This settlement would make a committed shipment answer impossible.');
+ }
+}
+function richeseBetrayalUseBlock(g: Game, owner: string): string | null {
+ const frame = g.pendingRicheseBetrayal;
+ if (!frame || !frame.receipt.required.includes(owner) || frame.receipt.passed.includes(owner))
+ return 'This seat has no current Richese Betrayal acknowledgement.';
+ const blocked = nexusRule(() => richeseBetrayalEligibility({players:g.players,cards:g.nexusCards!.cards!},owner));
+ if (blocked) return blocked;
+ const p = getPlayer(g,owner), promises = liveShipmentPromises(g.shipmentPromises ?? [],owner,g.turn);
+ if (promises.length) {
+ const projected:Game = {...g,nexusCards:{...g.nexusCards!,
+ cards:nexusRule(() => discardNexusCard(g.nexusCards!.cards!,owner,g.players))}};
+ if (!findShipmentCompletion(projected,p,promises))
+ return 'Spending this Nexus card would make your committed shipment answer impossible.';
+ }
+ return null;
+}
+function richeseBetrayalIntegrity(g: Game): void {
+ const cursor = g.richeseBetrayal, frame = g.pendingRicheseBetrayal;
+ if (!g.richeseBetrayalPreview) {
+ requireRule(!cursor && !frame,'Richese Betrayal lost its explicit preview profile.');
+ requireRule(!g.currentAuctionSale?.recipient,'The bank override lost its Richese Betrayal receipt.');
+ return;
+ }
+ requireRule(richeseBetrayalModeSupported(g) && cursor,'The Richese Betrayal profile lost its supported inventory or cursor.');
+ nexusCardsIntegrity(g);
+ nexusRule(() => validateRicheseBetrayalCursor(g,cursor,frame?.receipt ?? null));
+ if (frame) {
+ requireRule(Object.keys(frame).sort().join(',') === 'lot,quote,receipt,signature' &&
+ frame.quote?.kind === 'sold' && frame.lot && typeof frame.signature === 'string',
+ 'The saved Richese Betrayal source frame is malformed.');
+ richeseBetrayalSourcePreflight(g,frame.quote);
+ requireRule(frame.signature === richeseBetrayalSourceSignature(g,frame) &&
+ JSON.stringify(frame.lot) === JSON.stringify(g.richeseAuction) &&
+ JSON.stringify(frame.quote) === JSON.stringify(richeseSettlementQuote(g)) &&
+ JSON.stringify(frame.receipt.invoice) === JSON.stringify(richeseBetrayalInvoice(g,frame.quote)) &&
+ frame.receipt.round === g.richeseBidding!.event && frame.receipt.lot === g.richeseAuction!.event &&
+ frame.receipt.turn === g.turn &&
+ JSON.stringify(frame.receipt.required) === JSON.stringify(richeseBetrayalResponders(g)),
+ 'The saved Richese Betrayal opportunity lost its original quote, custody, responders or source continuation.');
+ }
+ const sale = g.currentAuctionSale;
+ const completed = cursor.completed.at(-1);
+ if (completed && completed.lot === g.richeseAuction?.event) {
+ requireRule(g.status === 'playing' && g.phase === 3 && completed.turn === g.turn &&
+ completed.round === g.richeseBidding?.event &&
+ !(completed.stage === 'used' && completed.invoice.kind === 'purchase') &&
+ sale && sale.winner === completed.invoice.buyer && sale.seller === completed.invoice.target &&
+ sale.origin === completed.invoice.source && sale.amount === completed.invoice.price && sale.free === false &&
+ sale.recipient === (completed.stage === 'used' ? 'bank' : undefined),
+ 'The completed Richese reaction lost or reopened its original paid-sale continuation.');
+ const responses = savedNoFieldResponses(g).filter(response =>
+ response.kind === 'emperorIncome' || response.kind === 'harkonnenBonus');
+ requireRule(responses.length === 1,'The completed Richese sale lost its native income or bonus continuation.');
+ currentAuctionContinuationQuote(g,{kind:'cancel',response:responses[0]});
+ }
+ if (sale?.recipient) {
+ const receipt = cursor.completed.at(-1);
+ requireRule(sale.recipient === 'bank' && receipt?.stage === 'used' && receipt.invoice.kind === 'sale' &&
+ receipt.lot === g.richeseAuction?.event && receipt.round === g.richeseBidding?.event &&
+ receipt.invoice.buyer === sale.winner && receipt.invoice.target === sale.seller &&
+ receipt.invoice.price === sale.amount && receipt.invoice.source === sale.origin,
+ 'The diverted auction invoice lost its committed source receipt.');
+ }
+}
+function beginRicheseBetrayal(g: Game, quote: Extract): boolean {
+ if (!g.richeseBetrayalPreview) return false;
+ richeseBetrayalSourcePreflight(g,quote);
+ const required = richeseBetrayalResponders(g);
+ if (!required.length) return false;
+ const cursor = g.richeseBetrayal!, lot = g.richeseAuction!, round = g.richeseBidding!;
+ const sequence = cursor.sequence + 1;
+ const receipt = nexusRule(() => createRicheseBetrayalReceipt(g,{
+ event:richeseBetrayalEvent(g.turn,sequence,round.event,lot.event),turn:g.turn,sequence,
+ round:round.event,lot:lot.event,invoice:richeseBetrayalInvoice(g,quote),
+ required,passed:[],stage:'pending',actor:null,nexusDiscardIndex:null,
+ }));
+ const frame:NonNullable = {receipt,lot:structuredClone(lot),quote:structuredClone(quote),signature:''};
+ frame.signature = richeseBetrayalSourceSignature(g,frame);
+ cursor.sequence = sequence;
+ cursor.current = receipt.event;
+ g.pendingRicheseBetrayal = frame;
+ return true;
+}
+function decideRicheseBetrayal(g: Game, p: Player, action: Action): void {
+ const frame = g.pendingRicheseBetrayal!, receipt = frame.receipt;
+ requireRule(['richeseBetrayalPass','richeseBetrayalUse'].includes(action.type) &&
+ action.event === receipt.event && Object.keys(action).every(key => ['type','event'].includes(key)) &&
+ receipt.required.includes(p.id) && !receipt.passed.includes(p.id),
+ 'Choose your exact current Richese Betrayal acknowledgement.');
+ const use = action.type === 'richeseBetrayalUse';
+ if (use) {
+ const blocked = richeseBetrayalUseBlock(g,p.id);
+ requireRule(!blocked,blocked ?? 'This Nexus card cannot be used.');
+ }
+ const passed = use ? receipt.passed : [...receipt.passed,p.id];
+ const stage = use ? 'used' : passed.length === receipt.required.length ? 'passed' : 'pending';
+ const next = nexusRule(() => createRicheseBetrayalReceipt(g,{
+ ...receipt,passed,stage,actor:use ? p.id : null,
+ nexusDiscardIndex:use ? g.nexusCards!.cards!.discard.length : null,
+ }));
+ if (stage === 'pending') {frame.receipt = next;return;}
+ if (use) g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!,p.id,g.players));
+ g.richeseBetrayal!.completed.push(next);
+ g.richeseBetrayal!.current = null;
+ g.pendingRicheseBetrayal = null;
+ if (use && receipt.invoice.kind === 'purchase') {
+ const card = frame.quote.card;
+ g.richeseCache = g.richeseCache!.filter(c => c.id !== card.id);
+ g.discard.push(card);
+ g.richeseFunding = {};
+ log(g,`${p.name} spent Richese Nexus Betrayal to discard ${card.name} before Richese could buy it. No purchase payment or acquisition occurred.`);
+ finishRicheseLot(g);
+ } else {
+ if (use) log(g,`${p.name} spent Richese Nexus Betrayal: the ${receipt.invoice.price}-spice Richese sale payment goes to the Spice Bank instead.`);
+ settleRicheseSoldLot(g,frame.quote,use);
+ }
+}
function settleRicheseLot(g: Game) {
const lot = g.richeseAuction!;
const quote = richeseSettlementQuote(g);
@@ -9405,8 +9613,12 @@ function settleRicheseLot(g: Game) {
} else richeseDecision(g, 'richeseUnbid');
return;
}
- const winner = getPlayer(g, quote.winner),
- card = quote.card;
+ if (beginRicheseBetrayal(g,quote)) return;
+ settleRicheseSoldLot(g,quote,false);
+}
+function settleRicheseSoldLot(g: Game, quote: Extract, bank: boolean) {
+ const lot = g.richeseAuction!, owner = getPlayer(g,lot.owner);
+ const winner = getPlayer(g, quote.winner), card = quote.card;
payWithAlly(g, winner, quote.amount, quote.allyPayment);
g.richeseFunding = {};
if (lot.source === 'cache')
@@ -9419,6 +9631,7 @@ function settleRicheseLot(g: Game) {
free: false,
origin: lot.source,
seller: owner.id,
+ ...(bank ? {recipient:'bank' as const} : {}),
...stampAuctionBureaucrat(g,winner,quote.amount,quote.allyPayment,owner.id),
};
log(
@@ -17416,6 +17629,7 @@ function marketGholaIntegrity(g: Game) {
nexusGuildSecretIntegrity(g);
nexusRicheseIntegrity(g);
richesePairIntegrity(g);
+ richeseBetrayalIntegrity(g);
nexusGuildCunningIntegrity(g);
traitorDeclarationIntegrity(g);
nexusInspectionIntegrity(g);
@@ -18055,7 +18269,7 @@ function reconcileShipmentPromises(
g: Game,
voluntary?: { actor: string; action: Action },
) {
- if (g.pendingKull) return;
+ if (g.pendingKull || g.pendingRicheseBetrayal) return;
const kept: ShipmentPromise[] = [];
for (const promise of g.shipmentPromises ?? []) {
if (promise.turn !== g.turn || promise.released || promise.fulfilled)
@@ -18118,7 +18332,7 @@ function reconcileBattlePromises(
g: Game,
voluntary?: { actor: string; action: Action },
) {
- if (g.pendingKull) return;
+ if (g.pendingKull || g.pendingRicheseBetrayal) return;
const b = g.battle;
if (
!b ||
@@ -24479,6 +24693,7 @@ function normalizeCardNames(g: Game) {
]);
}
export function applyAction(state: Game, id: string, action: Action): Game {
+ richeseBetrayalIntegrity(state);
kullIntegrity(state);
advancedPreviewIntegrity(state);
harkonnenExchangeIntegrity(state);
@@ -24562,6 +24777,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
return normalizeAutomaticGame(state);
}
const g = applyActionInner(state, id, action);
+ if (g.pendingRicheseBetrayal) {richeseBetrayalIntegrity(g);return g;}
if (g.pendingKull) {
settleAutomaticContinuations(g);
if (g.pendingKull) { kullIntegrity(g); return g; }
@@ -24677,6 +24893,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
nexusEcazInquiryIntegrity(g);
nexusEcazDukeIntegrity(g);
richesePairIntegrity(g);
+ richeseBetrayalIntegrity(g);
marketGholaIntegrity(g);
homeworldRule(() => homeworldGameIntegrity(g));
homeworldBattleLossIntegrity(g);
@@ -24691,6 +24908,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
return g;
}
function finishActionContinuations(g: Game) {
+ if (g.pendingRicheseBetrayal) return;
if (g.pendingKull) return;
finishLeaderSkillCustody(g);
finishMandatorySkillVisibility(g);
@@ -24753,10 +24971,12 @@ function finishActionContinuations(g: Game) {
finishMoritaniPlacement(g);
}
function settleAutomaticContinuations(g: Game) {
+ if (g.pendingRicheseBetrayal) return;
if (g.pendingKull?.stage === 'offer') return;
if (pendingNexusTraitors(g)) return;
if (g.pendingNullentropy) return;
for (let iteration = 0; iteration < 128; iteration++) {
+ if (g.pendingRicheseBetrayal) return;
if (g.truthtrance || g.phaseOpening || g.status === 'finished') return;
const response = g.response;
const before = JSON.stringify(g);
@@ -24778,6 +24998,7 @@ function settleAutomaticContinuations(g: Game) {
finishResponse(g, false);
} else if (!finishAutomaticDecision(g)) return;
finishActionContinuations(g);
+ if (g.pendingRicheseBetrayal) return;
// Opposing/automatic consequences can release a now-impossible promise;
// they are not the original actor voluntarily spending a promised resource.
reconcileBattlePromises(g);
@@ -24793,6 +25014,7 @@ function settleAutomaticContinuations(g: Game) {
}
/** Internal authoritative continuation. Callers must persist with their usual CAS fence. */
export function normalizeAutomaticGame(state: Game): Game {
+ richeseBetrayalIntegrity(state);
kullIntegrity(state);
advancedPreviewIntegrity(state);
harkonnenExchangeIntegrity(state);
@@ -24826,6 +25048,7 @@ export function normalizeAutomaticGame(state: Game): Game {
ecazCollectionIntegrity(state);
ecazAllianceIntegrity(state);
const g = structuredClone(state);
+ if (g.pendingRicheseBetrayal) return g;
if (g.pendingKull?.stage === 'offer') return g;
ornithopterIntegrity(g);
lateDefenseIntegrity(g);
@@ -24853,6 +25076,7 @@ export function normalizeAutomaticGame(state: Game): Game {
nexusEcazInquiryIntegrity(g);
nexusEcazDukeIntegrity(g);
richesePairIntegrity(g);
+ richeseBetrayalIntegrity(g);
marketGholaIntegrity(g);
homeworldRule(() => homeworldGameIntegrity(g));
homeworldBattleLossIntegrity(g);
@@ -24875,6 +25099,12 @@ function applyActionInner(
): Game {
ecazTreacheryIntegrity(state);
const g = structuredClone(state);
+ if (g.pendingRicheseBetrayal) {
+ const p = getPlayer(g,id);
+ if (action?.type === 'advanceBots') return g;
+ decideRicheseBetrayal(g,p,action);
+ return g;
+ }
if (g.biddingEnd && action.type !== 'advanceBots' &&
!(action.type === 'biddingEnd' && action.mode === 'ready'))
g.biddingEnd.ready = [];
@@ -24937,6 +25167,8 @@ function applyActionInner(
);
requireRule(g.status !== 'finished', 'This game has ended.');
const t = action.type;
+ requireRule(t !== 'richeseBetrayalPass' && t !== 'richeseBetrayalUse',
+ 'This Richese Betrayal opportunity is no longer pending.');
if (g.pendingKull) {
if (t === 'advanceBots') return g;
if (g.pendingKull.stage === 'offer') {
@@ -28695,6 +28927,7 @@ function applyActionInner(
throw new RuleError('That action is not available.');
}
export function viewGame(state: Game, id: string) {
+ richeseBetrayalIntegrity(state);
kullIntegrity(state);
advancedPreviewIntegrity(state);
harkonnenExchangeIntegrity(state);
@@ -29196,6 +29429,17 @@ export function viewGame(state: Game, id: string) {
? g.stormDials
: null,
me: id,
+ richeseBetrayalPreview: !!g.richeseBetrayalPreview,
+ richeseBetrayalReaction: g.pendingRicheseBetrayal
+ ? (() => {
+ const receipt = g.pendingRicheseBetrayal.receipt, invoice = receipt.invoice;
+ const hasPassed = receipt.passed.includes(id);
+ const canPass = receipt.required.includes(id) && !hasPassed;
+ const blocked = richeseBetrayalUseBlock(g,id);
+ return {event:receipt.event,kind:invoice.kind,target:invoice.target,buyer:invoice.buyer,
+ source:invoice.source,price:invoice.price,canPass,hasPassed,canUse:canPass && !blocked,blocked};
+ })()
+ : null,
kullPreview: !!g.kullPreview,
kullCounterEvent: g.pendingKull?.stage === 'counter' ? g.pendingKull.event : null,
karamaBlocked: g.kullPreview && kullBlocksKarama(g.kullRestrictions, g.turn, g.phase, id)
@@ -29334,7 +29578,7 @@ export function viewGame(state: Game, id: string) {
g.moritaniRetention.keep,
) ?? null)
: null,
- decision: g.pendingKull?.stage === 'offer' ? null :
+ decision: g.pendingRicheseBetrayal || g.pendingKull?.stage === 'offer' ? null :
g.decision?.kind === 'capturedLeader' &&
!g.leaderSkills?.assignments.some((a) => a.leader === (g.decision as Extract).leader) &&
![g.decision.player, g.decision.controller ?? g.decision.owner].includes(
@@ -29346,7 +29590,7 @@ export function viewGame(state: Game, id: string) {
: g.decision?.kind === 'guildShipment' && g.decision.noFieldSkillProof !== undefined
? { ...g.decision, noFieldSkillProof: undefined }
: (g.decision ?? null),
- response: g.pendingKull?.stage === 'offer' ? null : g.response
+ response: g.pendingRicheseBetrayal || g.pendingKull?.stage === 'offer' ? null : g.response
? {
...g.response,
...(g.response.bureaucratPayment ? {bureaucratPayment:undefined,bureaucratPaymentEvent:undefined} : {}),
@@ -29370,7 +29614,7 @@ export function viewGame(state: Game, id: string) {
}
: null,
paymentIncome: currentFactionPayment(g),
- responseControls: g.pendingKull?.stage === 'offer' ? undefined : g.response
+ responseControls: g.pendingRicheseBetrayal || g.pendingKull?.stage === 'offer' ? undefined : g.response
? {
cancelCards: responseCancelCards(g, me, g.response),
hasPassed: g.response.passed.includes(id),
diff --git a/game/nexus-richese-betrayal.ts b/game/nexus-richese-betrayal.ts
new file mode 100644
index 00000000..1ba5e1e2
--- /dev/null
+++ b/game/nexus-richese-betrayal.ts
@@ -0,0 +1,242 @@
+import type { Card } from './cards';
+import { nexusCardMode, validateNexusCards, validateNexusPlayers, type NexusPlayer, type NexusState } from './nexus-cards';
+import type { RicheseAuction } from './richese-auction';
+import { richeseCardDefinition } from './richese-cards';
+import type { RicheseSettlementQuote } from './richese-settlement';
+
+export type RicheseBetrayalInvoice = {
+ kind: 'purchase' | 'sale';
+ target: string;
+ buyer: string;
+ source: 'cache' | 'blackMarket';
+ price: number;
+ ownPayment: number;
+ allyPayment: number;
+ donor: string | null;
+ /** The original invoice recipient; null means the Spice Bank. */
+ originalRecipient: string | null;
+ /** Private physical evidence, never a public reaction field. */
+ card: Card;
+};
+export type RicheseBetrayalContext = {
+ turn: number;
+ players: readonly NexusPlayer[];
+};
+export type RicheseBetrayalReceipt = {
+ version: 1;
+ event: string;
+ turn: number;
+ sequence: number;
+ round: string;
+ lot: string;
+ invoice: RicheseBetrayalInvoice;
+ required: string[];
+ passed: string[];
+ stage: 'pending' | 'passed' | 'used';
+ actor: string | null;
+ nexusDiscardIndex: number | null;
+ signature: string;
+};
+export type RicheseBetrayalCursor = {
+ sequence: number;
+ current: string | null;
+ completed: RicheseBetrayalReceipt[];
+};
+export class RicheseBetrayalError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'RicheseBetrayalError';
+ }
+}
+function requireBetrayal(value: unknown, message: string): asserts value {
+ if (!value) throw new RicheseBetrayalError(message);
+}
+const plain = (value: unknown): value is Record =>
+ !!value && typeof value === 'object' && !Array.isArray(value) &&
+ [Object.prototype, null].includes(Object.getPrototypeOf(value));
+const exact = (value: unknown, keys: readonly string[]): boolean =>
+ plain(value) && Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key));
+const text = (value: unknown): value is string =>
+ typeof value === 'string' && value.trim().length > 0 &&
+ !['__proto__', 'constructor', 'prototype'].includes(value);
+const whole = (value: unknown): value is number =>
+ typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
+const ids = (value: unknown): value is string[] =>
+ Array.isArray(value) && value.every(text) && new Set(value).size === value.length;
+const cardKinds: Record = {
+ projectile: true, poison: true, lasgun: true, shield: true, snooper: true,
+ poisonBlade: true, shieldSnooper: true, weirdingWay: true, chemistry: true,
+ poisonTooth: true, artillery: true, worthless: true, hero: true, special: true,
+};
+function validCard(card: Card): boolean {
+ return plain(card) && exact(card, card.effect === undefined ? ['id', 'name', 'kind'] : ['id', 'name', 'kind', 'effect']) &&
+ text(card.id) && text(card.name) && Object.hasOwn(cardKinds, card.kind) &&
+ (card.effect === undefined || text(card.effect));
+}
+function sameCard(a: Card, b: Card): boolean {
+ return a.id === b.id && a.name === b.name && a.kind === b.kind && a.effect === b.effect;
+}
+function validatePlayers(players: readonly NexusPlayer[]): void {
+ try { validateNexusPlayers(players); }
+ catch (error) { throw new RicheseBetrayalError(error instanceof Error ? error.message : 'Invalid Richese Betrayal roster.'); }
+ requireBetrayal(players.some(p => p.faction === 'richese'), 'Richese Betrayal requires the native Richese seat.');
+}
+function validateInvoice(players: readonly NexusPlayer[], invoice: RicheseBetrayalInvoice): void {
+ requireBetrayal(exact(invoice, ['kind', 'target', 'buyer', 'source', 'price', 'ownPayment', 'allyPayment', 'donor', 'originalRecipient', 'card']),
+ 'Richese Betrayal has an invalid original invoice.');
+ const target = players.find(p => p.id === invoice.target);
+ const buyer = players.find(p => p.id === invoice.buyer);
+ requireBetrayal(target?.faction === 'richese' && buyer &&
+ ['cache', 'blackMarket'].includes(invoice.source) &&
+ whole(invoice.price) && invoice.price > 0 && whole(invoice.ownPayment) && whole(invoice.allyPayment) &&
+ invoice.allyPayment <= invoice.price && invoice.ownPayment === invoice.price - invoice.allyPayment &&
+ (invoice.allyPayment === 0 ? invoice.donor === null :
+ text(invoice.donor) && invoice.donor !== invoice.buyer && players.some(p => p.id === invoice.donor)) &&
+ validCard(invoice.card), 'Richese Betrayal needs a positive original price, exact funding split and physical card.');
+ if (invoice.kind === 'purchase') {
+ requireBetrayal(invoice.source === 'cache' && buyer.id === target.id && richeseCardDefinition(invoice.card) &&
+ invoice.originalRecipient === (players.find(p => p.faction === 'emperor')?.id ?? null),
+ 'Richese self-veto requires a real public cache purchase paid to Emperor or bank.');
+ } else {
+ requireBetrayal(invoice.kind === 'sale' && buyer.id !== target.id && invoice.originalRecipient === target.id &&
+ (invoice.source !== 'cache' || richeseCardDefinition(invoice.card)),
+ 'Richese sale diversion requires another buyer and income originally owed to Richese.');
+ }
+}
+
+/** Quote the original invoice only. Veto pays nobody; diversion changes the
+ * recipient to the bank, never the buyer, price, source or contribution split. */
+export function quoteRicheseBetrayalInvoice(input: {
+ players: readonly NexusPlayer[];
+ lot: Readonly;
+ quote: Extract;
+ donor: string | null;
+ originalRecipient: string | null;
+ sourceCards: readonly Card[];
+ physicalCards: readonly Card[];
+}): RicheseBetrayalInvoice {
+ requireBetrayal(plain(input), 'Richese Betrayal needs a source invoice.');
+ validatePlayers(input.players);
+ const { lot, quote } = input;
+ requireBetrayal(plain(lot) && plain(quote) && quote.kind === 'sold' && text(lot.event) &&
+ lot.outcome?.kind === 'sold' && lot.outcome.winner === quote.winner && lot.outcome.amount === quote.amount &&
+ lot.cardId === quote.card?.id && ['normal', 'onceAround', 'silent'].includes(lot.method) &&
+ !(lot.source === 'cache' && lot.method === 'normal'), 'Richese Betrayal needs the original ordinary sold lot.');
+ const invoice: RicheseBetrayalInvoice = {
+ kind: quote.winner === lot.owner ? 'purchase' : 'sale',
+ target: lot.owner, buyer: quote.winner, source: lot.source,
+ price: quote.amount, ownPayment: quote.ownPayment, allyPayment: quote.allyPayment,
+ donor: input.donor, originalRecipient: input.originalRecipient,
+ card: { ...quote.card },
+ };
+ validateInvoice(input.players, invoice);
+ const buyer = input.players.find(p => p.id === invoice.buyer)!;
+ requireBetrayal(!invoice.donor || (buyer.ally === invoice.donor &&
+ input.players.find(p => p.id === invoice.donor)?.ally === buyer.id),
+ 'Richese Betrayal cannot substitute an unallied contributor.');
+ for (const cards of [input.sourceCards, input.physicalCards]) {
+ requireBetrayal(Array.isArray(cards) && cards.every(validCard), 'Richese Betrayal needs physical source custody.');
+ const matches = cards.filter(card => card.id === invoice.card.id);
+ requireBetrayal(matches.length === 1 && sameCard(matches[0], invoice.card),
+ 'Richese Betrayal has lost or duplicated its exact source card.');
+ }
+ return invoice;
+}
+
+/** Public held-presence only: private Nexus identities never affect timing. */
+export function requiredRicheseBetrayalResponders(
+ players: readonly (NexusPlayer & { nexusHeld: boolean })[],
+): string[] {
+ validatePlayers(players);
+ requireBetrayal(players.every(p => typeof p.nexusHeld === 'boolean'), 'Richese Betrayal needs public Nexus held-presence.');
+ return players.filter(p => p.faction !== 'richese' && !p.ally && p.nexusHeld).map(p => p.id);
+}
+
+/** No printed spice fee. The physical singleton Richese Nexus is the cost;
+ * runtime separately preflights the unchanged purchase promises and budget. */
+export function richeseBetrayalEligibility(
+ input: { players: readonly NexusPlayer[]; cards: NexusState }, owner: string,
+): string | null {
+ validatePlayers(input.players);
+ try { validateNexusCards(input.cards, input.players); }
+ catch (error) { throw new RicheseBetrayalError(error instanceof Error ? error.message : 'Invalid physical Nexus custody.'); }
+ const seat = input.players.find(p => p.id === owner);
+ if (!seat) return 'Choose a seated Richese Nexus holder.';
+ if (seat.ally) return 'An allied player cannot play a Nexus card.';
+ if (input.cards.hands[owner] !== 'richese' ||
+ nexusCardMode('richese', seat.faction, input.players.map(p => p.faction)) !== 'betrayal')
+ return 'Hold the real Richese Nexus card as another faction to use Betrayal.';
+ return null;
+}
+
+export function richeseBetrayalEvent(turn: number, sequence: number, round: string, lot: string): string {
+ return JSON.stringify(['nexusRicheseBetrayal', turn, sequence, round, lot]);
+}
+function signature(receipt: RicheseBetrayalReceipt): string {
+ const i = receipt.invoice;
+ return JSON.stringify(['richeseBetrayalReceipt', receipt.version, receipt.event, receipt.turn,
+ receipt.sequence, receipt.round, receipt.lot, i.kind, i.target, i.buyer, i.source,
+ i.price, i.ownPayment, i.allyPayment, i.donor, i.originalRecipient,
+ [i.card.id, i.card.name, i.card.kind, i.card.effect ?? null],
+ receipt.required, receipt.passed, receipt.stage, receipt.actor, receipt.nexusDiscardIndex]);
+}
+
+/** Intrinsic historical evidence, independent of current card/discard custody
+ * and later alliances. Signatures detect inconsistency, not arbitrary forgery. */
+export function validateRicheseBetrayalReceipt(context: RicheseBetrayalContext, receipt: RicheseBetrayalReceipt): void {
+ requireBetrayal(plain(context) && whole(context.turn) && context.turn > 0, 'Richese Betrayal needs a valid current turn.');
+ validatePlayers(context.players);
+ requireBetrayal(exact(receipt, ['version', 'event', 'turn', 'sequence', 'round', 'lot', 'invoice', 'required', 'passed', 'stage', 'actor', 'nexusDiscardIndex', 'signature']) &&
+ receipt.version === 1 && whole(receipt.turn) && receipt.turn > 0 && receipt.turn <= context.turn &&
+ whole(receipt.sequence) && receipt.sequence > 0 && text(receipt.round) && text(receipt.lot) &&
+ receipt.event === richeseBetrayalEvent(receipt.turn, receipt.sequence, receipt.round, receipt.lot) &&
+ ids(receipt.required) && receipt.required.length > 0 && ids(receipt.passed) &&
+ receipt.required.every(id => context.players.some(p => p.id === id && p.faction !== 'richese')) &&
+ receipt.passed.every(id => receipt.required.includes(id)), 'Richese Betrayal has lost its original event or acknowledgements.');
+ validateInvoice(context.players, receipt.invoice);
+ if (receipt.stage === 'used') {
+ requireBetrayal(text(receipt.actor) && receipt.required.includes(receipt.actor) && !receipt.passed.includes(receipt.actor) &&
+ whole(receipt.nexusDiscardIndex), 'Richese Betrayal has lost its one physical Nexus spend.');
+ } else {
+ requireBetrayal((receipt.stage === 'pending' || receipt.stage === 'passed') && receipt.actor === null && receipt.nexusDiscardIndex === null &&
+ (receipt.stage === 'passed' ? receipt.passed.length === receipt.required.length :
+ receipt.turn === context.turn && receipt.passed.length < receipt.required.length),
+ 'Richese Betrayal has an invalid pending or all-pass lifecycle.');
+ }
+ requireBetrayal(receipt.signature === signature(receipt), 'Richese Betrayal receipt has changed its original invoice.');
+}
+export function createRicheseBetrayalReceipt(
+ context: RicheseBetrayalContext,
+ input: Omit,
+): RicheseBetrayalReceipt {
+ const receipt: RicheseBetrayalReceipt = {
+ ...input, version: 1, invoice: { ...input.invoice, card: { ...input.invoice.card } },
+ required: [...input.required], passed: [...input.passed], signature: '',
+ };
+ receipt.signature = signature(receipt);
+ validateRicheseBetrayalReceipt(context, receipt);
+ return receipt;
+}
+
+/** The independent cursor makes missing/orphaned private frames fail closed. */
+export function validateRicheseBetrayalCursor(
+ context: RicheseBetrayalContext, cursor: RicheseBetrayalCursor, pending: RicheseBetrayalReceipt | null,
+): void {
+ requireBetrayal(exact(cursor, ['sequence', 'current', 'completed']) && whole(cursor.sequence) &&
+ (cursor.current === null || text(cursor.current)) && Array.isArray(cursor.completed),
+ 'Richese Betrayal has an invalid continuation cursor.');
+ requireBetrayal(cursor.completed.length === cursor.sequence - (pending ? 1 : 0),
+ 'Richese Betrayal has lost a completed or pending continuation.');
+ for (const [index, receipt] of cursor.completed.entries()) {
+ validateRicheseBetrayalReceipt(context, receipt);
+ requireBetrayal(receipt.stage !== 'pending' && receipt.sequence === index + 1,
+ 'Richese Betrayal history must contain each completed opportunity once.');
+ }
+ requireBetrayal(new Set(cursor.completed.map(r => r.event)).size === cursor.completed.length,
+ 'Richese Betrayal has duplicate completed events.');
+ if (pending) {
+ validateRicheseBetrayalReceipt(context, pending);
+ requireBetrayal(pending.stage === 'pending' && pending.sequence === cursor.sequence && cursor.current === pending.event,
+ 'Richese Betrayal has lost ownership of its original pending continuation.');
+ } else requireBetrayal(cursor.current === null, 'Richese Betrayal has an orphaned continuation cursor.');
+}
diff --git a/game/reference.ts b/game/reference.ts
index c6590098..fbc3b1e8 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -195,7 +195,7 @@ export const RULE_TOPICS: RuleTopic[] = [
summary:
'Track implementation, player controls, AI, documentation and verification separately for each reviewed feature.',
steps: [
- 'The linked topics contain a five-part checklist. Implemented describes only the stated feature boundary; Partial identifies remaining work and Planned means it is not active.',
+ 'The linked topics contain a five-part checklist. Implemented describes only the stated feature boundary; Partial identifies remaining work. Planned means that facet is not complete: for Verification it means evidence is still pending, not that a connected preview is inactive.',
'Verification entries identify focused regression suites. Passing those checks does not certify every interaction, browser journey or complete expansion game.',
'Development stages distinguish Missing functions, working Prototypes, Integrated functions, Verified behavior and Polished presentation. A prototype stage does not certify complete rules coverage; check the stated limits and evidence.',
'This checklist is being extended across the rules. Features without a checklist still have a topic-level coverage label and are not implicitly complete.',
@@ -211,6 +211,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'truthtrance-spice',
'ecaz-ambassadors',
'richese-cards',
+ 'nexus-richese-betrayal',
'richese-acquisition',
'richese-gift',
'distrans-transfer',
@@ -411,6 +412,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Development tables support the cache auction first or last, with Once Around or Silent bidding. Advanced Black Market offers a hand card using normal, Once Around or Silent bidding; other players may buy it, and an unbid or canceled offer returns to the seller.',
'The controls show the card only to entitled viewers, accept a public Black Market claim, and let bidders choose their own and pledged ally spice. Silent bids remain private until all eligible players submit. A donor cannot reduce pledged funding during unfinished Silent bidding, and a Black Market card reserved for sale cannot be played elsewhere. Atreides inspection, applicable Harkonnen bonus draws and Ixian allied replacement follow the sale’s origin.',
'Other buyers pay Richese for a cache or Black Market sale. Richese buying its own cache card pays the Emperor or bank. Only the winner’s committed funding is collected; losing Silent bids do not spend their spice. An unbid cache card may be kept for free if the hand has room, or removed from the game.',
+ '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.',
'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.',
'Some unresolved rules are explicitly blocked: changing the normal card count after cache-auction cancellation, exhausted-cache arithmetic, positive Black Market self-bids, and advanced Ixian Technology substitution on special lots. These are implementation guards while the rules are resolved, not claims of official prohibitions.',
@@ -420,6 +422,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'choam-modules',
'faction-richese',
'richese-acquisition',
+ 'nexus-richese-betrayal',
'richese-gift',
'nullentropy-search',
'juice-of-sapho',
@@ -557,6 +560,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'In advanced development tables, Richese can use its special Karama once per game. Select an available Karama in your hand and one eligible card from your own cache. The activating Karama is discarded; the chosen card enters your hand secretly.',
'This is a purchase for Richese itself. Pay three of your available spice to Emperor when present, otherwise the bank. The general Emperor purchase-income rule supplies this payment destination. An ally receives no Harkonnen bonus or Ixian replacement from this direct cache acquisition.',
'A Karama cannot cancel the special purchase itself. Emperor income has a separate response: canceling that income does not undo the purchase or restore the spent special power.',
+ 'The separate Richese Nexus Betrayal preview connects ordinary public self-cache auctions, not this private special acquisition. The native Karama cancellation exception above is not authority for deciding whether Nexus Betrayal applies to a special purchase.',
'The private hand panel provides both card selectors and enlarged inspection. It shows the payment recipient and the server’s current availability reason. A reserved auction card cannot be taken from that lot. Spending pledged or already committed funds is not allowed.',
'Full-hand activation is guarded pending a ruling on when the spent Karama leaves the hand. Buying the final cached card is also guarded pending the empty-cache auction rule. Pending interactions may need to finish first. These development limits are not claimed as printed prohibitions.',
'All four AI profiles can choose the canonical Richese Karama when the projection allows it and enough spice remains. They leave other cache effects to future implementation and give pending responses, decisions and Truthtrance priority.',
@@ -567,6 +571,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'richese-gift',
'nullentropy-search',
'special-karama',
+ 'nexus-richese-betrayal',
'card-richese-karama',
'implementation-checklist',
],
@@ -4044,6 +4049,40 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area: 'Verification', status: 'Partial', detail: 'Focused Basic/Advanced rules, controls, bots, JSON, browser owner/rival projection and SQLite restart/CAS cover stale cross-lot offers, hand/custody rejection and duplicate requests; broader module acceptance remains open.', evidence: ['tests/nexus-emperor-secret-ally.test.ts', 'tests/nexus-emperor-secret-ally-recovery.test.ts', 'tests/nexus-emperor-purchase-engine.test.ts', 'tests/nexus-emperor-purchase-recovery.test.ts', 'tests/nexus-emperor-secret-ally-purchase-controls.test.ts'] },
],
},
+ {
+ id: 'nexus-richese-betrayal',
+ title: 'Richese Nexus Betrayal: veto purchase or divert sale payment',
+ category: 'Advanced & expansions',
+ coverage: 'Partial',
+ developmentStage: 'Verified',
+ summary: 'Before a supported Richese auction settles, an unallied rival may spend the physical Richese Nexus card to discard a public self-cache lot without payment, or keep another buyer’s purchase intact while sending the original sale payment to the Spice Bank.',
+ steps: [
+ 'This is an explicit local Richese Betrayal preview, not a public expansion start. It supports fresh two-to-six-seat Basic or Advanced tables with CHOAM, Richese and otherwise classic factions, the physical CHOAM deck and Nexus alone. Other optional modules, expansion factions, Semuta and Kull previews remain outside this boundary; older games do not silently acquire the effect.',
+ 'To use either alternative, remain unallied and hold the singleton Richese Nexus card while another seat is native Richese. A successful Use discards that physical Nexus card once. It spends no ordinary Treachery card, hand slot, Karama or activation spice. Current own-card promises and committed-resource legality still apply.',
+ 'The opportunity opens after a valid winning quote but before any purchase payment, source removal or hand delivery. You do not choose a different card, buyer, price or recipient: those terms are bound to the original auction.',
+ 'Purchase veto applies to Richese winning its own public cache lot through Once Around or Silent bidding. The exact selected cache card enters ordinary Treachery discard instead of Richese’s hand. No buyer or ally spice is spent; no payment goes to Richese, Emperor or bank, and no purchase bonus is created. The original lot retires once.',
+ 'Sale diversion applies when another buyer owes a positive payment to Richese for a cache auction or an Advanced Black Market auction using normal, Once Around or Silent bidding. The same buyer receives the same physical card for the same price and own/authorized-ally split. That original payment goes to the bank, with zero sale income for Richese; it is not an extra charge, refund or second purchase.',
+ 'A diverted sale still earns the original buyer consequences. In particular, an applicable Harkonnen purchase bonus continues once. Losing offers do not spend spice. A vetoed purchase, zero/free cache keep or unsold Black Market retention does not fabricate a bonus or a positive sale.',
+ 'Your voluntary Nexus spend must preserve your own binding shipment promises. If the involuntary purchase veto makes an opponent’s promise impossible, the existing native reconciliation may release that promise; it does not pretend Richese acquired the vetoed card or paid the price.',
+ 'All publicly possible responders receive the same neutral acknowledgement: unallied non-Richese seats with public held-Nexus presence. A seat may pass even if its secret card is irrelevant; only a privately eligible Richese-card holder sees Use. If no seat is publicly possible, settlement is automatic. This privacy-preserving application protocol is not publisher-prescribed timing or a general Nexus/Karama priority ruling.',
+ 'Pass spends nothing and keeps your Nexus card. Your recorded pass cannot be undone within this event. Once every required responder passes, the original quoted purchase settles once with its original payment recipient, card delivery and earned consequences. An accepted Use closes the opportunity with its one selected outcome.',
+ 'The panel shows the public Richese target, original buyer, cache/Black Market source and winning price. It does not show payer resources, ally balances, rival hands, secret Nexus identities or the saved private receipt. Read your own unavailable reason rather than guessing Use from a held-card face alone.',
+ 'A public cache face remains inspectable through existing entitled Card controls. A concealed Black Market face stays unknown unless you already have a separate inspection entitlement; a seller’s public claim is not its face. Nexus inspection shows printed reference text, not anybody’s hidden custody. This response grants no new card inspection.',
+ 'Normal-deck Richese self-purchases are outside the connected veto boundary because the Richese-family identity may be hidden. The private advanced special-Karama acquisition has its own activating card and once-use ordering and is also outside this boundary. Native Karama exceptions do not certify Nexus coverage of either producer.',
+ 'Existing exhausted-cache, cache-ordering, positive Black Market seller-bid and special-acquisition guards remain. Unsupported counters or any-time overlays must not enter this saved purchase boundary. Finish the response before underlying bidding or other actions can change its reserved card or funding.',
+ '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.',
+ ],
+ example: 'Richese wins a revealed cache lot for 4: Betrayal veto discards that card, charges nobody and delivers nothing. If Harkonnen instead buys it for 4, sale diversion charges the original four spice once, sends it to the bank, delivers the card and preserves the applicable Harkonnen bonus. If everyone passes, the original purchase settles unchanged: Richese’s self-buy pays Emperor/bank; the other buyer’s sale pays Richese.',
+ related: ['nexus-cards', 'richese-cards', 'richese-acquisition', 'bidding', 'alliance-funding', 'privacy', 'faction-richese', 'implementation-checklist'],
+ checklist: [
+ { area: 'Implementation', status: 'Partial', detail: 'Bounded public self-cache veto and other-buyer cache/Black Market recipient override bind original source, invoice and continuation with independent pending/completed history. Hidden normal self-buys, special acquisition and combined responses remain excluded.', evidence: ['game/nexus-richese-betrayal.ts', 'game/engine.ts', 'game/auction-continuation-quote.ts'] },
+ { area: 'Player controls', status: 'Partial', detail: 'Mounted preview controls separate public neutral pass/wait from private legal Use, retain existing entitled inspectors and suspend the underlying auction panel without revealing the unknown Black Market face.', evidence: ['components/nexus-richese-betrayal.tsx', 'components/game-table.tsx'] },
+ { area: 'AI', status: 'Partial', detail: 'All four minimal legal policies consume canonical own-view eligibility and acknowledge publicly required opportunities; full strategy and calibration remain unfinished.', evidence: ['game/bot-nexus-richese-betrayal.ts', 'game/bots.ts'] },
+ { area: 'Documentation', status: 'Partial', detail: 'Prepayment veto, same-invoice diversion, singleton cost, pass behavior, neutral protocol, entitled faces and excluded producer/counter boundaries are explained without full-family or publisher-timing claims.', evidence: ['docs/NEXUS_RICHESE_RULES.md', 'docs/NEXUS_CARD_RULES.md', 'docs/RULE_DECISIONS.md'] },
+ { area: 'Verification', status: 'Partial', detail: '80 focused cases and 5,953 offline checks cover both alternatives/all-pass, exact custody/escrow/bonus, identity parity, four legal bot profiles, real table controls and authenticated SQLite restart/races/orphan rejection. Actual CLI/runtime and phone controls prove Basic veto, Advanced all-pass and cache/Black Market diversion with refresh. Local HTTP 51/55 retains three 503s and one timeout; full-family, wider-module and deployed acceptance remain open.', evidence: ['tests/nexus-richese-betrayal.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-controls.test.tsx', 'tests/bot-nexus-richese-betrayal.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'docs/NEXUS_RICHESE_RULES.md'] },
+ ],
+ },
{
id: 'nexus-cards',
title: 'Nexus Cards',
@@ -4091,18 +4130,19 @@ export const RULE_TOPICS: RuleTopic[] = [
'When Ecaz is absent, an unallied holder may spend its Nexus card at a quiet play boundary to ask whether any one player, including themself, holds one of the holder’s native faction leaders as an ordinary Traitor Card. The owner receives a private yes/no answer saved at the moment of use; no name or count is disclosed. Dead or captured native leaders count, but foreign Gholas, Duke Vidal, Cheap Hero and Face Dancers do not. Native-leader scope and private-only audience are explicit product interpretations, not publisher rulings; exceptional controlled-leader and public-disclosure combinations remain open.',
'When Richese is absent, its Secret Ally replaces your ordinary reserve shipment with up to five physical forces charged as one. Select the explicit shipment checkbox; forces, elite identity, destination restrictions, Homeworld reserve sources and normal movement remain unchanged. Fremen remain free within their normal reinforcement range; Guild and an active Karama rate retain their existing tariff and payment routing. This is not a concealed No-Field or an extra shipment.',
'Native Richese Cunning has a bounded two-No-Field shipment in genuine Richese/CHOAM Nexus games without other optional modules. Choose distinct unused tokens: one reveals immediately into reserve-capped public forces, the other remains concealed; pay for one marker and spend the Nexus. The previous shipment token cannot be either selection; the concealed token becomes next last-used. Karama or Guild special prevention preserves physical tokens and unpaid spice, with the ordinary stopped-shipment allowance policy. Other module combinations and the full faceup-component display remain gated.',
+ 'Richese Betrayal has a separate opt-in auction preview. Before payment, an unallied rival may spend its physical Richese Nexus card to discard a public self-cache win without any purchase debit, or redirect another buyer’s cache/Black Market payment to the bank while preserving delivery and earned bonus. Every publicly possible holder acknowledges the same opportunity; private Use eligibility cannot determine public timing. Normal hidden self-purchases and private special-Karama acquisitions are not connected. See the dedicated guide; its verification remains Planned.',
'Guild Cunning is declared when finishing your ordinary Shipment and Movement turn. It spends the Nexus and opens one Karama response. Allowance grants a second native shipment at normal Guild prices, including supported cross-shipment, return and Homeworld routes. Cancellation leaves the completed first turn intact. Only an unused Hajr move can follow the second shipment; no ordinary movement or third move is granted. The physical Ornithopter card combination remains unavailable.',
'With Guild absent, its Secret Ally is an explicit optional source for one ordinary shipment at Guild prices. It supports physical reserve shipment, cross-shipment and return to abstract reserves, plus half-price independently legal world-to-world shipment. Fremen may retain free reinforcement or choose the paid card route beyond its normal range, outside the storm. Native faction, typed sources and normal movement stay unchanged. Homeworld reserve return awaits a ruling; arbitrary foreign-world transport and concealed No-Fields are not granted.',
'Cunning spends its Nexus card and uses the native Mentat placement opportunity. A Karama response precedes placement; cancellation leaves the token where it was and the Nexus spent. Later Terror entry still uses the existing one-token stack selection and supported effect rules. If Extortion was revealed this turn, its bank award and payment choices follow that placement response; this does not implement Atomics or complete Moritani games.',
'Hidden reactive Betrayal timing remains unresolved for other effects. Complete module games are unfinished, so public module starts remain disabled.',
],
- related: ['ecaz-modules', 'spice-blow', 'alliance-funding', 'choam-worthless', 'choam-kull'],
+ related: ['ecaz-modules', 'spice-blow', 'alliance-funding', 'choam-worthless', 'choam-kull', 'nexus-richese-betrayal'],
checklist: [
- { area: 'Implementation', status: 'Partial', detail: 'Physical lifecycle, Atreides inspections, Harkonnen exchanges/declarations, Face Dancers, Suboids, advisors, native Voice, Emperor Sardaukar and Betrayal, bounded Fremen revival/Cunning, CHOAM effects, Moritani Cunning/Betrayal, Ecaz Betrayal, native Duke Cunning and private Secret Ally inquiry, Richese shipment, and bounded Emperor bank-auction purchase are integrated. Ecaz Cunning transfers the existing Duke at a quiet Battle boundary with a real card and distinct turn-end expiry. Ecaz inquiry scope/audience and Emperor purchase timing are provisional. Borrowed Voice, exceptional Duke custody, other reactions, Tleilaxu revival, Fremen worm protection and combined Cunning interactions remain unfinished.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/nexus-ecaz-inquiry.ts', 'game/nexus-ecaz-duke.ts', 'game/duke-vidal.ts', 'game/nexus-emperor-secret-ally.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/engine.ts'] },
- { area: 'Player controls', status: 'Partial', detail: 'Private lifecycle, inspections, exchanges, Tleilaxu refresh, Ixian activation, advisor selection, temporary Sardaukar and CHOAM effects, Moritani Betrayal, Ecaz Betrayal and owner-only Secret Ally inquiry, native Duke Cunning, Richese shipment, Emperor purchase and Fremen Cunning controls are connected. Remaining effect controls are unfinished.', evidence: ['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/nexus-ecaz-duke.tsx', 'components/game-table.tsx'] },
- { area: 'AI', status: 'Partial', detail: 'All four profiles choose legal actions through private offers, including Moritani/Ecaz Betrayal, Ecaz Secret Ally inquiry and native Duke Cunning, Fremen Cunning and Emperor purchase. Complete module strategy and calibration remain unfinished.', evidence: ['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/nexus-ecaz-duke-options.ts', 'game/nexus-richese-options.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts'] },
- { area: 'Documentation', status: 'Partial', detail: 'Common rules, twelve printed faces and bounded runtime contracts retain explicit unresolved Ecaz Duke capture/Tanks/Ghola and inquiry audience/scope, Fremen nested Cunning, Great Maker, combined modules, fresh advisors, storm and borrowed Voice.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_FREMEN_RULES.md', 'docs/NEXUS_ECAZ_RULES.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md'] },
- { area: 'Verification', status: 'Partial', detail: 'Physical custody, commitments, controls, legal bots, Basic/Advanced and in-memory SQLite recovery regressions include native Ecaz Duke override and private Secret Ally inquiry. A read-only local browser fixture showed the owner-only Duke choice and no rival offer; no room was written. Full module games, exceptional Duke custody and combined interactions remain separate.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-controls.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts'] },
+ { area: 'Implementation', status: 'Partial', detail: 'Physical lifecycle, Atreides inspections, Harkonnen exchanges/declarations, Face Dancers, Suboids, advisors, native Voice, Emperor Sardaukar and Betrayal, bounded Fremen revival/Cunning, CHOAM effects, Moritani Cunning/Betrayal, Ecaz Betrayal, native Duke Cunning and private Secret Ally inquiry, Richese shipment and bounded auction Betrayal, and bounded Emperor bank-auction purchase are integrated. Ecaz Cunning transfers the existing Duke at a quiet Battle boundary with a real card and distinct turn-end expiry. Ecaz inquiry scope/audience and Emperor purchase timing are provisional. Borrowed Voice, exceptional Duke custody, other reactions, Tleilaxu revival, Fremen worm protection and combined Cunning interactions remain unfinished.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/nexus-ecaz-inquiry.ts', 'game/nexus-ecaz-duke.ts', 'game/duke-vidal.ts', 'game/nexus-emperor-secret-ally.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/engine.ts'] },
+ { area: 'Player controls', status: 'Partial', detail: 'Private lifecycle, inspections, exchanges, Tleilaxu refresh, Ixian activation, advisor selection, temporary Sardaukar and CHOAM effects, Moritani Betrayal, Ecaz Betrayal and owner-only Secret Ally inquiry, native Duke Cunning, Richese shipment and bounded auction Betrayal, Emperor purchase and Fremen Cunning controls are connected. Remaining effect controls are unfinished.', evidence: ['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/nexus-ecaz-duke.tsx', 'components/nexus-richese-betrayal.tsx', 'components/game-table.tsx'] },
+ { area: 'AI', status: 'Partial', detail: 'All four profiles choose legal actions through private offers, including Moritani/Ecaz Betrayal, Ecaz Secret Ally inquiry and native Duke Cunning, Fremen Cunning, Emperor purchase and bounded Richese auction Betrayal. Complete module strategy and calibration remain unfinished.', evidence: ['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/nexus-ecaz-duke-options.ts', 'game/nexus-richese-options.ts', 'game/bot-nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts'] },
+ { area: 'Documentation', status: 'Partial', detail: 'Common rules, twelve printed faces and bounded runtime contracts retain explicit unresolved Ecaz Duke capture/Tanks/Ghola and inquiry audience/scope, Fremen nested Cunning, Great Maker, combined modules, fresh advisors, storm and borrowed Voice. Richese auction Betrayal has a separate cost/prepayment/privacy contract and excluded-producer boundaries.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_FREMEN_RULES.md', 'docs/NEXUS_ECAZ_RULES.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_RICHESE_RULES.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md'] },
+ { area: 'Verification', status: 'Partial', detail: 'Physical custody, commitments, controls, legal bots, Basic/Advanced and in-memory SQLite recovery regressions include native Ecaz Duke override and private Secret Ally inquiry. A read-only local browser fixture showed the owner-only Duke choice and no rival offer; no room was written. Bounded Richese auction Betrayal additionally has local CLI/runtime, authenticated SQLite and refreshed phone Use/Pass evidence in owned disposable rooms. Full module games, exceptional Duke custody, combined interactions and deployed acceptance remain separate.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-controls.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts'] },
],
},
{
@@ -4375,7 +4415,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'advanced-combat',
...(id === 'ix-modules' ? ['tech-tokens'] : []),
...(id === 'choam-modules' ? ['stronghold-cards', 'leader-skills'] : []),
- ...(id === 'choam-modules' ? ['richese-cards'] : []),
+ ...(id === 'choam-modules' ? ['richese-cards', 'nexus-richese-betrayal'] : []),
...(id === 'ecaz-modules'
? [
'faction-moritani',
diff --git a/game/table-turn.ts b/game/table-turn.ts
index e84c50a4..ea77b93f 100644
--- a/game/table-turn.ts
+++ b/game/table-turn.ts
@@ -12,12 +12,13 @@ type TurnView = Pick<
| 'phaseOpening'
| 'automaticContinuationPending'
> &
- Partial>;
+ Partial>;
/** The single current decision owner for the table banner and seat highlight.
* Shared response windows have no single acting seat. Only public view fields
* participate; this does not decide which gameplay actions are legal. */
export function tableActionOwner(g: TurnView): string | null {
+ if (g.richeseBetrayalReaction) return null;
if (g.kullReaction && g.status === 'playing')
return g.players.some(p => p.id === g.kullReaction!.player)
? g.kullReaction.player : null;
diff --git a/tests/bot-nexus-richese-betrayal.test.ts b/tests/bot-nexus-richese-betrayal.test.ts
new file mode 100644
index 00000000..0e957cd5
--- /dev/null
+++ b/tests/bot-nexus-richese-betrayal.test.ts
@@ -0,0 +1,101 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { applyAction, viewGame } from '../game/engine';
+import type { Game } from '../game/engine';
+import { botActions } from '../game/bots';
+import { DIFFICULTIES } from '../game/bot-profiles';
+import { createRicheseBetrayalFixture } from './fixture-richese-betrayal';
+
+function saved(game: Game): Game {
+ return JSON.parse(JSON.stringify(game));
+}
+
+void test('all four profiles commit the saved purchase veto and sale diversion from their private projection', () => {
+ for (const profile of DIFFICULTIES) {
+ for (const kind of ['purchase', 'sale'] as const) {
+ const fixture = createRicheseBetrayalFixture(kind);
+ fixture.game.players.find(player => player.id === fixture.holder)!.bot = profile;
+ const game = saved(fixture.game);
+ const own = viewGame(game, fixture.holder);
+ // No rival hand or resources may be needed to choose the response.
+ for (const rival of own.players) {
+ if (rival.id !== own.me) {
+ delete rival.hand;
+ delete rival.spice;
+ }
+ }
+ const reaction = own.richeseBetrayalReaction!;
+ assert.equal(reaction.canUse, true);
+ const actions = botActions(own);
+ assert.deepEqual(actions, [{ type: 'richeseBetrayalUse', event: reaction.event }]);
+ const balances = game.players.map(player => player.spice);
+ const buyerCount = game.players.find(player => player.id === fixture.buyer)!.hand.length;
+ const result = saved(applyAction(game, fixture.holder, actions[0]));
+ assert.equal(viewGame(result, fixture.holder).richeseBetrayalReaction, null);
+ assert.ok(result.nexusCards?.cards?.discard.includes('richese'));
+ if (kind === 'purchase') {
+ assert.deepEqual(result.players.map(player => player.spice), balances);
+ assert.equal(result.players.find(player => player.id === fixture.buyer)!.hand.length, buyerCount);
+ assert.ok(result.discard.some(card => card.id === fixture.card));
+ } else {
+ assert.equal(result.players.find(player => player.id === fixture.target)!.spice,
+ game.players.find(player => player.id === fixture.target)!.spice);
+ assert.equal(result.players.find(player => player.id === fixture.buyer)!.spice,
+ game.players.find(player => player.id === fixture.buyer)!.spice - reaction.price);
+ assert.ok(result.players.find(player => player.id === fixture.buyer)!.hand.some(card => card.id === fixture.card));
+ }
+ }
+ }
+});
+
+void test('all profiles pass publicly possible nonholder boundaries through saved original settlement', () => {
+ for (const profile of DIFFICULTIES) {
+ for (const kind of ['purchase', 'sale'] as const) {
+ const fixture = createRicheseBetrayalFixture(kind, { holderNexus: 'choam' });
+ for (const player of fixture.game.players) player.bot = profile;
+ let game = saved(fixture.game);
+ const original = viewGame(game, fixture.holder).richeseBetrayalReaction!;
+ assert.equal(original.canUse, false);
+ for (let acknowledgements = 0; acknowledgements < game.players.length; acknowledgements++) {
+ const responder = game.players.find(player => {
+ const reaction = viewGame(game, player.id).richeseBetrayalReaction;
+ return reaction?.canPass && !reaction.hasPassed;
+ });
+ if (!responder) break;
+ const view = viewGame(game, responder.id);
+ const actions = botActions(view);
+ assert.deepEqual(actions, [{ type: 'richeseBetrayalPass', event: original.event }]);
+ game = saved(applyAction(game, responder.id, actions[0]));
+ if (viewGame(game, responder.id).richeseBetrayalReaction) {
+ assert.deepEqual(botActions(viewGame(game, responder.id)), []);
+ }
+ }
+ assert.equal(viewGame(game, fixture.holder).richeseBetrayalReaction, null);
+ assert.ok(game.players.find(player => player.id === fixture.buyer)!.hand.some(card => card.id === fixture.card));
+ assert.equal(game.players.find(player => player.id === fixture.buyer)!.spice,
+ fixture.game.players.find(player => player.id === fixture.buyer)!.spice - original.price);
+ assert.equal(game.nexusCards?.cards?.hands[fixture.holder], 'choam');
+ }
+ }
+});
+
+void test('projected restrictions defeat physical Richese Nexus hints without exposing underlying bot actions', () => {
+ for (const profile of DIFFICULTIES) {
+ const fixture = createRicheseBetrayalFixture('purchase');
+ fixture.game.players.find(player => player.id === fixture.holder)!.bot = profile;
+ const view = viewGame(saved(fixture.game), fixture.holder);
+ assert.equal(view.nexusCards?.card, 'richese');
+ const reaction = view.richeseBetrayalReaction!;
+ reaction.canUse = false;
+ reaction.blocked = 'The committed promise prevents spending this Nexus card.';
+ assert.deepEqual(botActions(view), [{ type: 'richeseBetrayalPass', event: reaction.event }]);
+ reaction.hasPassed = true;
+ assert.deepEqual(botActions(view), []);
+ reaction.hasPassed = false;
+ reaction.canPass = false;
+ assert.deepEqual(botActions(view), []);
+ reaction.canPass = true;
+ view.status = 'finished';
+ assert.deepEqual(botActions(view), []);
+ }
+});
diff --git a/tests/fixture-richese-betrayal.ts b/tests/fixture-richese-betrayal.ts
new file mode 100644
index 00000000..a9a6ba25
--- /dev/null
+++ b/tests/fixture-richese-betrayal.ts
@@ -0,0 +1,129 @@
+import assert from 'node:assert/strict';
+import {
+ applyAction, createGame, initializeRicheseBetrayalGameForAudit, joinGame,
+ newPlayer, viewGame, type Game,
+} from '../game/engine';
+import { botActions } from '../game/bots';
+import { drawNexusCard, type NexusState } from '../game/nexus-cards';
+import { orderNexusSpice } from './fixture-nexus-cards';
+
+export const RICHESE_BETRAYAL_FIXTURE_FACTIONS = ['richese','choam','harkonnen','fremen'] as const;
+export type RicheseBetrayalFixtureOptions = {
+ seatIds?: string[];
+ source?: 'cache' | 'blackMarket';
+ advanced?: boolean;
+ holderNexus?: 'richese' | 'choam';
+ allyPayment?: number;
+ allyIsSeller?: boolean;
+ allySpice?: number;
+ beforeFinalBid?: (g: Game) => void;
+ bonusCounter?: boolean;
+};
+export type RicheseBetrayalFixture = {
+ game: Game;
+ holder: string;
+ other: string;
+ target: string;
+ buyer: string;
+ card: string;
+ event: string;
+ ally: string | null;
+ counterCard: string | null;
+};
+function step(g: Game): Game {
+ for (const p of g.players) {
+ const v = viewGame(g,p.id);
+ v.players.find(seat => seat.id === p.id)!.bot = 'Easy';
+ const action = botActions(v)[0];
+ if (action) return applyAction(g,p.id,action);
+ }
+ throw new Error(`Richese Betrayal setup stalled at ${g.status}/${g.phase}/${g.decision?.kind}`);
+}
+function holdNexus(g: Game, owner: string, card: 'richese' | 'choam' | 'guild') {
+ const cards = g.nexusCards!.cards!;
+ assert.ok(cards.deck.includes(card));
+ const ordered: NexusState = {...cards,deck:[card,...cards.deck.filter(c => c !== card)]};
+ g.nexusCards!.cards = drawNexusCard(ordered,owner,g.players,() => 0);
+}
+/** Genuine setup and completed Spice phase, conserved physical Nexus draws and
+ * real declaration/bid actions. Arrange resources only before the auction, never
+ * rewrite a paused receipt. Seat IDs are fixed before setup for authenticated SQL. */
+export function createRicheseBetrayalFixture(
+ kind: 'purchase' | 'sale', options: RicheseBetrayalFixtureOptions = {},
+): RicheseBetrayalFixture {
+ const ids = options.seatIds ?? ['richese','holder','buyer','other'];
+ assert.equal(ids.length,4);
+ const [target,holder,harkonnen,other] = ids;
+ const source = options.source ?? (kind === 'purchase' ? 'cache' : 'blackMarket');
+ assert.ok(kind !== 'purchase' || source === 'cache');
+ const advanced = options.advanced ?? source === 'blackMarket';
+ assert.ok(source !== 'blackMarket' || advanced);
+ let g = createGame('RICHESEBETRAYAL',newPlayer(target,'Richese','richese'),advanced,['choam']);
+ for (let i = 1; i < ids.length; i++)
+ joinGame(g,newPlayer(ids[i],RICHESE_BETRAYAL_FIXTURE_FACTIONS[i],RICHESE_BETRAYAL_FIXTURE_FACTIONS[i]));
+ g.nexusCards = {cards:null,phase:null};
+ for (const p of g.players) g = applyAction(g,p.id,{type:'ready'});
+ g = initializeRicheseBetrayalGameForAudit(g);
+ for (let i = 0; g.status === 'setup' && i < 100; i++) g = step(g);
+ assert.equal(g.status,'playing');
+ for (const p of g.players) {g.deck.push(...p.hand.splice(0));p.spice = 20;}
+ for (let i = 0; g.phase === 0 && i < 100; i++) g = step(g);
+ assert.equal(g.phase,1);
+ orderNexusSpice(g,['land','land']);
+ for (let i = 0; g.phase === 1 && i < 100; i++) g = step(g);
+ assert.equal(g.phase,2);
+ assert.equal(g.nexusCards!.phase!.stage,'complete');
+ holdNexus(g,holder,options.holderNexus ?? 'richese');
+ const contribution = options.allyPayment ?? 0;
+ const buyer = kind === 'purchase' ? target : harkonnen;
+ let ally: string | null = null;
+ if (contribution) {
+ assert.equal(kind,'sale');
+ assert.ok(contribution > 0 && contribution <= 6);
+ ally = options.allyIsSeller ? target : other;
+ g.players.find(p => p.id === harkonnen)!.ally = ally;
+ g.players.find(p => p.id === ally)!.ally = harkonnen;
+ } else holdNexus(g,other,'guild');
+ const card = source === 'cache' ? g.richeseCache![0].id : (() => {
+ const index = g.deck.findIndex(c => c.kind === 'shield');
+ assert.ok(index >= 0);
+ const selected = g.deck.splice(index,1)[0];
+ g.players.find(p => p.id === target)!.hand.push(selected);
+ return selected.id;
+ })();
+ for (const id of g.order) g = applyAction(g,id,{type:'ready'});
+ while (g.decision?.kind === 'choamMarket')
+ g = applyAction(g,g.decision.player,{type:'decision',done:true});
+ assert.equal(g.phase,3);
+ if (source === 'cache') {
+ if (g.decision?.kind === 'richeseBlackMarket')
+ g = applyAction(g,target,{type:'decision',event:g.richeseBidding!.event,decline:true});
+ g = applyAction(g,target,{type:'decision',event:g.richeseBidding!.event,position:'first'});
+ }
+ assert.equal(g.decision?.kind,source === 'cache' ? 'richeseCache' : 'richeseBlackMarket');
+ g = applyAction(g,target,{type:'decision',event:g.richeseBidding!.event,card,method:'onceAround',direction:'clockwise'});
+ assert.equal(g.response,null);
+ let counterCard: string | null = null;
+ if (options.bonusCounter) {
+ const index = g.deck.findIndex(c => c.effect === 'karama');
+ assert.ok(index >= 0);
+ const counter = g.deck.splice(index,1)[0];
+ g.players.find(p => p.id === other)!.hand.push(counter);
+ counterCard = counter.id;
+ }
+ if (ally) {
+ if (options.allySpice !== undefined) g.players.find(p => p.id === ally)!.spice = options.allySpice;
+ g = applyAction(g,ally,{type:'pledgeAid',amount:contribution});
+ }
+ for (let i = 0; !g.pendingRicheseBetrayal && i < 10; i++) {
+ const lot = g.richeseAuction!;
+ assert.ok(lot && !lot.outcome && lot.active);
+ if (lot.acted.length === lot.order.length - 1) options.beforeFinalBid?.(g);
+ g = applyAction(g,lot.active,{type:'richeseBid',event:lot.event,
+ amount:lot.active === buyer ? 6 : kind === 'purchase' && !lot.bidder ? 1 : null,
+ ...(lot.active === buyer ? {allyPayment:contribution} : {})});
+ }
+ assert.ok(g.pendingRicheseBetrayal);
+ assert.equal(g.pendingRicheseBetrayal.receipt.invoice.kind,kind);
+ return {game:g,holder,other,target,buyer,card,event:g.pendingRicheseBetrayal.receipt.event,ally,counterCard};
+}
diff --git a/tests/nexus-richese-betrayal-controls.test.tsx b/tests/nexus-richese-betrayal-controls.test.tsx
new file mode 100644
index 00000000..4a3c9f31
--- /dev/null
+++ b/tests/nexus-richese-betrayal-controls.test.tsx
@@ -0,0 +1,144 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { registerHooks } from 'node:module';
+import { readFileSync } from 'node:fs';
+import { Children, createElement, isValidElement } from 'react';
+import type { ReactNode, ComponentProps } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { applyAction, viewGame } from '../game/engine';
+import type { Action, Game, GameView } from '../game/engine';
+import { createRicheseBetrayalFixture } from './fixture-richese-betrayal';
+import { DEFAULT_ROOM_CONTROL } from '../lib/room-control';
+
+const aliases = registerHooks({
+ resolve(specifier, context, next) {
+ return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
+ },
+ load(url, context, next) {
+ if (!url.endsWith('.module.css')) return next(url, context);
+ const css = readFileSync(new URL(url), 'utf8');
+ const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
+ return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true };
+ },
+});
+// Static imports load Next and CSS before these Node-only SSR alias hooks.
+const { NexusRicheseBetrayal } = await import('../components/nexus-richese-betrayal');
+const { GameTable } = await import('../components/game-table');
+
+function markup(game: GameView, busy = false): string {
+ return renderToStaticMarkup(createElement(NexusRicheseBetrayal, { game, act() {}, busy }));
+}
+
+function click(node: ReactNode, prefix: string): boolean {
+ for (const child of Children.toArray(node)) {
+ if (!isValidElement<{ children?: ReactNode; onClick?: () => void }>(child)) continue;
+ if (typeof child.props.children === 'string' && child.props.children.startsWith(prefix) && child.props.onClick) {
+ child.props.onClick();
+ return true;
+ }
+ if (click(child.props.children, prefix)) return true;
+ }
+ return false;
+}
+
+void test('only the canonical private holder can use while public possible nonholders acknowledge the same auction', () => {
+ const holderFixture = createRicheseBetrayalFixture('purchase');
+ const nonholderFixture = createRicheseBetrayalFixture('purchase', { holderNexus: 'choam' });
+ const holder = viewGame(JSON.parse(JSON.stringify(holderFixture.game)), holderFixture.holder);
+ const nonholder = viewGame(JSON.parse(JSON.stringify(nonholderFixture.game)), nonholderFixture.holder);
+ assert.match(markup(holder), /