From 3bf7d26e805b6a683cb2ceb95fcb5e6a5e99b7ee Mon Sep 17 00:00:00 2001
From: Quintine <15965533+Quintine@users.noreply.github.com>
Date: Sun, 25 Oct 2026 23:14:17 +1100
Subject: [PATCH] Connect Richese Nexus to Karama acquisitions
---
components/nexus-richese-betrayal.tsx | 28 ++-
docs/CURRENT_STATUS.md | 42 +++-
docs/IMPLEMENTATION_STATUS.md | 39 +++
docs/NEXUS_RICHESE_RULES.md | 85 ++++++-
docs/RICHESE_ACQUISITION_RULES.md | 12 +
docs/RULE_DECISIONS.md | 21 +-
game/auction-continuation-quote.ts | 62 +----
game/bot-nexus-richese-betrayal.ts | 3 +-
game/engine.ts | 175 ++++++++++++--
game/nexus-richese-betrayal.ts | 64 ++++-
game/normal-auction-retirement.ts | 103 ++++++++
game/reference.ts | 18 +-
tests/fixture-richese-karama-betrayal.ts | 222 ++++++++++++++++++
tests/nexus-emperor-special-purchase.test.ts | 7 +-
...-richese-betrayal-karama-controls.test.tsx | 156 ++++++++++++
tests/nexus-richese-betrayal-karama.test.ts | 194 +++++++++++++++
tests/normal-auction-retirement.test.ts | 184 +++++++++++++++
tests/richese-karama-betrayal-engine.test.ts | 208 ++++++++++++++++
18 files changed, 1513 insertions(+), 110 deletions(-)
create mode 100644 game/normal-auction-retirement.ts
create mode 100644 tests/fixture-richese-karama-betrayal.ts
create mode 100644 tests/nexus-richese-betrayal-karama-controls.test.tsx
create mode 100644 tests/nexus-richese-betrayal-karama.test.ts
create mode 100644 tests/normal-auction-retirement.test.ts
create mode 100644 tests/richese-karama-betrayal-engine.test.ts
diff --git a/components/nexus-richese-betrayal.tsx b/components/nexus-richese-betrayal.tsx
index 106c2f8c..933d1be2 100644
--- a/components/nexus-richese-betrayal.tsx
+++ b/components/nexus-richese-betrayal.tsx
@@ -17,7 +17,10 @@ export function NexusRicheseBetrayal({ game, act, busy }: {
const canRespond = reaction.canPass && !reaction.hasPassed;
const canUse = canRespond && reaction.canUse && !reaction.blocked;
const disabled = busy || game.status !== 'playing';
- const blind = reaction.source === 'normal';
+ const karama = reaction.source === 'karama';
+ const specialKarama = reaction.source === 'specialKarama';
+ const intended = karama || specialKarama;
+ const blind = reaction.source === 'normal' || karama;
// The receipt grants no card-face entitlement. Only the already-public cache
// face is shown; concealed ordinary and Black Market faces stay private.
const card = reaction.source === 'cache' && game.richeseAuction?.source === 'cache'
@@ -26,12 +29,16 @@ export function NexusRicheseBetrayal({ game, act, busy }: {
return (
- Richese Nexus Betrayal · before payment
+ Richese Nexus Betrayal · {intended ? 'before acquisition' : 'before payment'}
- {buyer?.name ?? 'The buyer'} won {reaction.source === 'cache' ? 'the public cache lot' : blind ? 'the concealed ordinary auction lot' : 'the concealed Black Market lot'} for {reaction.price} spice.
+ {karama
+ ? `${buyer?.name ?? 'Richese'} intends to acquire the concealed ordinary auction lot using ordinary Karama. No spice would be paid.`
+ : specialKarama
+ ? `${buyer?.name ?? 'Richese'} intends to acquire a privately selected Richese cache card using its native special Karama. The listed price is 3 spice; the chosen face remains private.`
+ : <>{buyer?.name ?? 'The buyer'} won {reaction.source === 'cache' ? 'the public cache lot' : blind ? 'the concealed ordinary auction lot' : 'the concealed Black Market lot'} for {reaction.price} spice.>}
{blind
? ' This response does not disclose the card’s family or grant inspection before you spend.'
- : reaction.kind === 'purchase'
+ : specialKarama ? '' : 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.`}
@@ -40,11 +47,18 @@ export function NexusRicheseBetrayal({ game, act, busy }: {
If it is a Richese card, that card is discarded without payment or delivery.
Otherwise your Nexus remains spent and the original purchase continues.
}
+ {intended &&
+ Provisional order: Betrayal resolves before Richese spends the costs of this intended acquisition.
+ If stopped, the intended card is discarded without acquisition and Richese keeps its unplayed activation Karama.
+ {specialKarama && ' The three spice are not paid and its once-per-game special use remains available.'}
+
}
{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.{' '}
+ Every publicly possible unallied responder acknowledges this boundary, regardless of their secret Nexus identity.{' '}
+ {intended
+ ? 'Passing lets the declared acquisition continue after all required acknowledgements; its costs and acquisition have not happened yet.'
+ : 'Passing continues the original auction after all required acknowledgements; no payment or acquisition has happened yet.'}{' '}
Betrayal timing and preview limits
{!canRespond ? (
@@ -67,7 +81,7 @@ export function NexusRicheseBetrayal({ game, act, busy }: {
disabled={disabled}
onClick={() => { if (!disabled) act({ type: 'richeseBetrayalUse', event: reaction.event }); }}
>
- {blind ? 'Spend Richese Nexus · attempt to stop this purchase' : reaction.kind === 'purchase' ? 'Use Richese Nexus · discard purchase without payment' : 'Use Richese Nexus · divert payment to Spice Bank'}
+ {blind ? 'Spend Richese Nexus · attempt to stop this purchase' : specialKarama ? 'Use Richese Nexus · stop this private acquisition' : reaction.kind === 'purchase' ? 'Use Richese Nexus · discard purchase without payment' : 'Use Richese Nexus · divert payment to Spice Bank'}
)}
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index b739fa3d..d3d0ad70 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -16,6 +16,44 @@ open mode gates.
## Current checkpoint and work
+**Karama acquisitions reach Richese Nexus veto — 25 October. Prototyped / Partial.**
+The existing preview now covers immediate ordinary Karama and private Advanced
+special purchase. Uniform acknowledgements precede original costs. Under the
+visible provisional policy, successful veto discards only the intended card;
+the activation Karama stays held, special price/once-use remain unspent, and
+an intended joint Emperor Nexus stays held. Concealed ordinary misses spend
+Nexus then execute the original free purchase once. All-pass runs the original
+paid/joint special intent, including native income and suspended parent controls.
+Unpaid ordinary lots retire without inventing a winner or sale. Public source
+adds `karama` / `specialKarama`; face, cost identity and private funding stay
+concealed. [Source and selected timing](NEXUS_RICHESE_RULES.md#karama-acquisition-betrayal--25-october-2026).
+
+Three disjoint lanes, bounded read-only review and root integration pass
+**19 files, 169/169**, types/lint/build and **14 actual engine/GameTable SSR
+and component-callback journeys** on unchanged source. New cases: eight invoice,
+eight unpaid-retirement, six controls/profile and fifteen engine. Ordinary
+Basic109/Advanced117 action prefixes inherit the labelled controlled recycle;
+quiet private104 and joint147 earn native draws without it. Parent128 preserves
+a real paid-auction Emperor-income response. Pass/veto/miss, actual competing
+bid, nominal3 payment, retained joint price, exact original costs, once-use and
+native continuation are exercised. Inventories remain physical. Controls stay
+visible until action; all four minimal profiles use projected legality.
+
+Fixture feedback corrects response shape, automatic income settlement when no
+Karama remains, and the joint target's live eligibility. The parent source
+really acquired another Karama, so its child income still pauses and preserves
+the original response. An older entitlement assertion now accepts independent
+cards already supported by the prior selected E1/E2 composition. One extracted
+helper callback receives a missing type annotation. No game rule or guard is
+weakened. Private source-bound report/logs:
+`/home/quintine/.local/share/dune/checkpoints/20261025-richese-karama-final/`;
+rendered journeys/HTML: `20261025-richese-karama-runtime/` alongside it.
+These are engine/SSR/callback evidence, not hydration, DOM/browser/mobile or
+deployed acceptance. Native full-deck exhaustion, wider combinations, full
+modes and final assurance remain open. No saved-room/D1/server/reset/restart/
+deployment operation occurred. Continue the remaining rule/checklist gaps;
+complete administration/non-AI work precedes strategic AI and final acceptance.
+
**Ordinary-deck Richese Nexus purchase veto — 25 October. Prototyped / Partial.**
A positive native Richese ordinary win now opens the existing neutral response
before payment choice. The card’s identity is concealed. Under the selected visible
@@ -48,8 +86,8 @@ legacy source-quote mutators only narrow the new frame union. No game rule or
start guard is weakened. Private source-bound logs/HTML:
`/home/quintine/.local/share/dune/checkpoints/20261025-richese-normal-betrayal/`.
No saved-room/D1/server/reset/restart/deployment operation or recovery campaign
-occurred. Proactive immediate/private special Karama producers, wider valid
-combinations, final assurance and exact-revision deployed acceptance remain due.
+occurred. The later Karama checkpoint above adds the immediate/private producers;
+wider valid combinations, final assurance and deployed acceptance remain due.
**Selected revival storm/HMS route reasons — 25 October.** Source review retains
the current routes: Southern's named placement does not inherit Fremen storm
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index a856bbe8..0465b74d 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,44 @@
# Dune implementation status
+## 25 October 2026 — Richese Nexus reaches original Karama acquisitions
+
+**Prototyped / Partial.** Immediate ordinary Karama and private Advanced special
+purchase now pause before their exact original costs in the existing Richese
+Betrayal preview. Successful veto discards the selected card while retaining
+unplayed Karama, special price/once-use and intended joint Emperor Nexus. This
+pre-cost order is explicitly provisional under the 14 October authority.
+Concealed ordinary misses spend Nexus then execute the original free purchase;
+all-pass resumes the native paid/joint intent and parent controls once. The
+ordinary retirement helper advances an unbought lot without fabricating a bid,
+sale or competing-bidder award. Public controls distinguish intended purchases,
+show nominal prices and keep selected faces/funding concealed. Source enum adds
+`karama` / `specialKarama`; action/event interfaces are unchanged.
+[Selected timing, sources and limits](NEXUS_RICHESE_RULES.md#karama-acquisition-betrayal--25-october-2026).
+
+Three disjoint lanes, bounded review and root integration pass **19 files,
+169/169**, types/lint/build and fourteen actual engine/GameTable SSR and isolated
+component-callback journeys. New invoice8/retirement8/controls6/engine15 cases
+cover both ordinary rules bands, veto/pass/miss, competing bid, paid private
+purchase, nested native Emperor income and joint intent. Ordinary Basic109/
+Advanced117 inherits the labelled recycle; quiet private104 and joint147 earn
+native draws without it. Parent128 has an actual paid income response. Printed
+main/cache and Nexus inventories remain physical; full deck exhaustion is not
+claimed. Minimal profiles acknowledge legal projections; strategy is deferred.
+
+The first frozen union163/169 exposes fixture assumptions about response amount,
+automatic income, a previously retained target and an old independent-card
+exclusion. Targeted fixes use native live sources and current prior entitlement.
+Quiet income auto-settles after its only Karama spends; the real paid-parent
+source has acquired another Karama, so child income still saves its parent.
+No hands, wallets, profiles or rule guards are altered to make cases pass.
+Final type feedback adds one missing helper annotation. Source-bound report:
+`/home/quintine/.local/share/dune/checkpoints/20261025-richese-karama-final/report.json`;
+rendered journeys/HTML in adjacent `20261025-richese-karama-runtime/`.
+These are engine/SSR/callback results, not parent hydration, DOM/browser/mobile
+or deployed proof. No saved-room/D1/server/reset/restart/deployment operation or
+recovery campaign occurs. Wider valid combinations, full modes, remaining
+non-AI features, final assurance and exact-revision deployed acceptance stay due.
+
## 25 October 2026 — Original normal auction reaches Richese Nexus purchase veto
**Ordinary-deck Richese Nexus purchase veto — 25 October. Prototyped / Partial.**
diff --git a/docs/NEXUS_RICHESE_RULES.md b/docs/NEXUS_RICHESE_RULES.md
index f25a7cc4..526168ab 100644
--- a/docs/NEXUS_RICHESE_RULES.md
+++ b/docs/NEXUS_RICHESE_RULES.md
@@ -2,6 +2,84 @@
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).
+## Karama acquisition Betrayal — 25 October 2026
+
+**Prototyped / Partial.** The printed Richese Nexus purchase-veto alternative
+has no origin restriction. Supplied physical16 permits ordinary Karama's free
+acquisition outside Richese auctions; physical36 / E2p4 permits the private
+native special purchase for Karama plus three spice, once per game. The printed
+special-power Karama immunity does not establish Nexus immunity. These two
+direct sources now enter the existing supported Betrayal preview.
+
+**Selected provisional pre-cost order:** under the 14 October recommended-option
+authority, prepare the original legal intent, then offer the neutral response
+before any activation cost. A successful veto discards only the intended card.
+The activation Karama stays held; private special purchase also retains its
+three spice and unused once-per-game power. A prepared joint Emperor Nexus
+stays held too. This is a recommended online composition, **not** a publisher
+timing clarification, refund or ruling that a used special power is restored.
+
+The ordinary lot remains concealed and uses the prior committed blind-attempt
+policy. Use first spends the physical Richese Nexus. Actual Richese-family:
+discard that lot and retire its native unpaid pool position once, without
+inventing a winning bid, sale or award to a competing bidder. Other family:
+Nexus stays spent, then the exact original Karama spends and acquires the lot
+free. All-pass retains Nexus and performs that same native acquisition once.
+The independent retirement helper preserves actual next opener, hand limits,
+inspection offer, remaining cards and last-cache transition.
+
+The private selected cache card is necessarily Richese-family, so this source
+has no blind miss. The face/ID and actual joint funding are never added to the
+public prompt. All-pass runs the original typed special intent once: its native
+costs, acquisition, once-use, separate Emperor income and suspended parent
+controls remain. Veto preserves those parent controls without making income.
+The nominal three-spice prompt does not identify a private Emperor Nexus choice.
+
+Private `preCost` frames bind the original unplayed cost, selected physical
+source, normalized special intent, wallet/reservations, live window and enclosing
+controls. They store no raw action for replay. Public reaction source additionally
+accepts `karama` and `specialKarama`; existing action/event fields are unchanged.
+Persistent controls distinguish intended acquisitions from won auctions and
+explain the selected ordering. All four minimal profiles pass concealed ordinary
+attempts and consume the canonical special-veto projection.
+
+Evidence: [invoice helper](../game/nexus-richese-betrayal.ts),
+[unbought retirement](../game/normal-auction-retirement.ts),
+[invoice cases](../tests/nexus-richese-betrayal-karama.test.ts),
+[retirement cases](../tests/normal-auction-retirement.test.ts),
+[controls/profile cases](../tests/nexus-richese-betrayal-karama-controls.test.tsx),
+[actual source fixtures](../tests/fixture-richese-karama-betrayal.ts),
+[engine cases](../tests/richese-karama-betrayal-engine.test.ts).
+Ordinary Richese-family recycling remains labelled controlled placement after
+real cost/discard; private special and joint sources earn their actual draws
+without that recycle. This section supersedes the earlier proactive/private
+producer exclusions only in the existing supported profile. Wider combinations,
+comprehensive assurance, full modes and exact-revision deployed acceptance stay
+open. Local engine/SSR evidence is not browser/hydration/deployed evidence.
+
+Frozen **19 files, 169/169**, types/lint/build and fourteen actual engine/
+GameTable SSR/component-callback journeys pass. New invoice8, retirement8,
+controls6 and engine15 cases include both ordinary rules bands, competing bid,
+private paid purchase, actual pending ordinary Emperor income and joint Emperor
+Nexus intent. Basic109/Advanced117 ordinary prefixes retain their labelled
+controlled recycle; quiet private104 and joint147 use native draws without it.
+Parent128 includes that earlier controlled recycle and actual paid income.
+Main/cache and Nexus inventories stay physical; no full exhaustion/reshuffle is
+claimed. The private control stays readable until an acknowledgement is made.
+
+Fixture repairs follow actual response shape and native income auto-settlement:
+quiet purchase spends its only opposing Karama, whereas the parent source really
+acquired a second Karama and still pauses its child income. Joint selection uses
+the remaining eligible cache rather than an already retained face. An older
+Emperor entitlement assertion is reconciled with previously delivered independent
+card support; one extracted-helper callback needs a type annotation. Runtime
+mechanics and profile guards are not relaxed. Private source-bound report:
+`/home/quintine/.local/share/dune/checkpoints/20261025-richese-karama-final/report.json`;
+journey data and rendered HTML are in the adjacent
+`20261025-richese-karama-runtime/` directory. No saved rooms, D1, running server,
+reset, restart, deployment or recovery campaign is involved. Browser/mobile,
+hydration, broader combinations, full modes and final assurance remain open.
+
## Ordinary winning-bid Betrayal — 25 October 2026
**Prototyped / Partial.** The printed purchase-veto alternative has no cache-
@@ -42,8 +120,8 @@ Fresh normal frames bind their original live pool index, round, opaque lot ID,
physical card, winning quote and source controls. Public event IDs contain no
card identifier. Intrinsic receipt shape and old cache/Black Market signature
bytes remain unchanged. The legacy alternatives keep their existing native
-continuations. Proactive immediate Karama acquisition and private special-
-Karama acquisition remain separate outstanding producers. Wider profile,
+continuations. Immediate and private special Karama producers are added in the
+later [Karama acquisition section](#karama-acquisition-betrayal--25-october-2026). Wider profile,
combination, recovery/privacy assurance and deployed acceptance are not claimed.
Evidence: [invoice/outcome helper](../game/nexus-richese-betrayal.ts),
@@ -86,8 +164,7 @@ played hand. Final type feedback replaces two mutations of a readonly fixture
array with equivalent malformed copies. No rules, profiles or start guards
are relaxed. Private matching code fingerprints, logs and rendered HTML:
`/home/quintine/.local/share/dune/checkpoints/20261025-richese-normal-betrayal/`.
-Exact native discard/reshuffle, proactive immediate Karama, private special
-acquisition, wider combinations, complete modes, final recovery/privacy/rules
+Exact native discard/reshuffle, wider combinations, complete modes, final recovery/privacy/rules
assurance and browser/mobile/deployed acceptance remain unfinished. These
journeys are engine/SSR, not parent hydration or DOM clicks. No saved-room/D1/
server/reset/restart/deployment operation occurred.
diff --git a/docs/RICHESE_ACQUISITION_RULES.md b/docs/RICHESE_ACQUISITION_RULES.md
index c6f3f0a4..9205d84d 100644
--- a/docs/RICHESE_ACQUISITION_RULES.md
+++ b/docs/RICHESE_ACQUISITION_RULES.md
@@ -13,6 +13,18 @@ Targeted searches found discussion titles concerning full hands and alliance can
## 1. Advanced special Karama purchase
+### Richese Nexus pre-cost response — 25 October 2026
+
+The supported Richese Betrayal preview now offers neutral acknowledgements
+after preparing the exact private purchase, before its activation costs. Under
+the explicitly provisional selected ordering, a veto discards only the intended
+cache card: Karama, three spice and special once-use remain unspent, including
+an intended joint Emperor Nexus. All-pass executes the original paid/joint
+intent and separate income once, preserving an already pending parent response.
+The nominal-price prompt grants no selected-face or private-funding inspection.
+Ordinary Karama immunity alone does not establish Nexus immunity.
+[Source, selected policy and evidence](NEXUS_RICHESE_RULES.md#karama-acquisition-betrayal--25-october-2026).
+
### Emperor Nexus joint purchase composition — 14 October 2026
The original source explicitly calls this a purchase; the printed Emperor
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index 0c50b9dd..1b79ad42 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -1,5 +1,21 @@
# Rule decision index
+## Richese Nexus before Karama acquisition costs — 25 October 2026
+
+Apply the printed purchase veto to native immediate ordinary Karama and private
+Advanced special purchases in the existing supported preview. Ordinary Karama
+special-power immunity does not itself establish Nexus immunity. Under the
+14 October recommended-option authority, resolve Betrayal **before** costs:
+successful veto discards only the intended card, retaining the unplayed Karama,
+special price and once-use, including a prepared joint Emperor Nexus. This is
+explicitly provisional ordering, not a printed refund/restore ruling. Concealed
+ordinary lots use the existing spend-before-family-check convention; a miss
+spends Nexus and resumes the exact free acquisition. All-pass runs the original
+paid/joint typed intent and native enclosing income/controls once. No selected
+face or private funding is disclosed. This supersedes prior producer exclusions
+only in the existing bounded preview.
+[Source, selected order and limits](NEXUS_RICHESE_RULES.md#karama-acquisition-betrayal--25-october-2026).
+
## Richese Nexus ordinary-purchase blind attempt — 25 October 2026
The printed Richese veto is not cache-only, but an ordinary recycled card's
@@ -12,8 +28,9 @@ quote does not reopen. No prior family flag or exact-face reveal is introduced.
This spend-on-miss convention is explicitly provisional online composition;
primary rules did not establish general voluntary Nexus discard or an identifying
family back. Existing cache/Black Market alternatives, private eligibility,
-native later payment choice and source/profile guards remain. Proactive and
-special Karama acquisition are still outstanding producers.
+native later payment choice and source/profile guards remain. The later
+[pre-cost acquisition decision](#richese-nexus-before-karama-acquisition-costs--25-october-2026)
+adds proactive and special Karama producers in the same supported profile.
[Printed source, knowledge policy and scope](NEXUS_RICHESE_RULES.md#ordinary-winning-bid-betrayal--25-october-2026).
## Revived placement retains storm and HMS routes — 25 October 2026
diff --git a/game/auction-continuation-quote.ts b/game/auction-continuation-quote.ts
index 3ddd5054..28237ecc 100644
--- a/game/auction-continuation-quote.ts
+++ b/game/auction-continuation-quote.ts
@@ -2,6 +2,7 @@ import type { Card } from './cards';
import type { FactionId } from './catalog';
import type { Game, ResponseWindow } from './engine';
import { requireRicheseDeclarationCache } from './richese-settlement';
+import { quoteNormalAuctionRetirement } from './normal-auction-retirement';
export type AuctionContinuationSeat = {
id: string;
@@ -444,61 +445,8 @@ function calculate(
after: round.position === 'first' ? 'normalPool' : 'phase',
});
}
- const a = input.auction!,
- index = a.index + 1,
- able = input.order.filter((id) => {
- const p = seated(id)!;
- return handCount(p) < p.handLimit;
- });
- if (index === a.cards.length || !able.length) {
- const round = input.richeseBidding,
- activeRound = round?.turn === input.turn && round.stage === 'normal';
- // User ruling 7 October 2026: the cache lot only runs when physical cache
- // cards remain and the compulsory auction was not canceled.
- const cacheLast =
- !!activeRound &&
- round.position === 'last' &&
- !round.cacheCanceled &&
- (input.richeseCacheCount ?? 0) > 0;
- return result({
- kind: 'normalEnd',
- returned: structuredClone(a.cards.slice(index)),
- after: cacheLast ? 'richeseCache' : 'phase',
- completeRound: !!activeRound && !cacheLast,
- });
- }
- let opener = a.opener;
- do {
- opener = (opener + 1) % input.order.length;
- } while (!able.includes(input.order[opener]));
- const active = input.order[opener],
- ix = input.players.find((p) => p.faction === 'ixians'),
- atreides = input.players.find((p) => p.faction === 'atreides');
- const offer: LotOffer =
- input.advanced &&
- ix &&
- handCount(ix) > 0 &&
- input.ixTechnologyTurn !== input.turn
- ? { kind: 'ixTechnology', player: ix.id }
- : atreides
- ? { kind: 'atreidesAuction', owner: atreides.id, passed: [] }
- : null;
- const nextAuction = structuredClone(a);
- delete nextAuction.saphoOrder;
- return result({
- kind: 'normalLot',
- auction: {
- ...nextAuction,
- index,
- bid: 0,
- allyPayment: 0,
- bidder: null,
- passed: [],
- opener,
- active,
- peekKnown: false,
- },
- active,
- offer,
- });
+ return result(quoteNormalAuctionRetirement({ ...input,
+ players: spending ? input.players.map(player => player.id === spending.player
+ ? { ...player, hand: player.hand.filter((card: { id: string }) => card.id !== spending.card) } : player) : input.players,
+ }));
}
diff --git a/game/bot-nexus-richese-betrayal.ts b/game/bot-nexus-richese-betrayal.ts
index 349a0b5c..77506122 100644
--- a/game/bot-nexus-richese-betrayal.ts
+++ b/game/bot-nexus-richese-betrayal.ts
@@ -8,9 +8,10 @@ export function botNexusRicheseBetrayalActions(game: GameView): Action[] {
!reaction.canPass || reaction.hasPassed) return [];
const own = game.players.find((player) => player.id === game.me);
if (!(own?.bot ?? own?.autopilot)) return [];
+ const blind = reaction.source === 'normal' || reaction.source === 'karama';
return [{
// Minimal policies keep the card instead of guessing a concealed ordinary lot.
- type: reaction.source !== 'normal' && reaction.canUse && !reaction.blocked ? 'richeseBetrayalUse' : 'richeseBetrayalPass',
+ type: !blind && reaction.canUse && !reaction.blocked ? 'richeseBetrayalUse' : 'richeseBetrayalPass',
event: reaction.event,
}];
}
diff --git a/game/engine.ts b/game/engine.ts
index 94c6d29e..794832b0 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -281,6 +281,7 @@ import {
quoteNormalAuctionBid,
quoteNormalAuctionNext,
} from './normal-auction';
+import { quoteNormalAuctionRetirement, NormalAuctionRetirementError, type NormalAuctionRetirementQuote } from './normal-auction-retirement';
import { reorderSaphoCyclicAuction, SaphoCyclicAuctionError } from './sapho-cyclic-auction';
import {
quoteNormalAuctionPeek,
@@ -531,7 +532,7 @@ import { STORM_START_SECTOR, PLAYER_CIRCLE_SECTORS } from './player-positions';
import { cashInCards } from './choam-karama';
import { kullBlocksKarama, activeKullRestrictions, validateKullPhaseRestrictions, validateKullAttemptStamp, kullNativeCostCards, kullNexusCostCards, kullCounterCards, type KullPhaseRestriction } from './choam-kull';
import { kullCancellationPurpose, kullSpecialPurpose, kullPurposeLabel, type KullPurpose } from './kull-purpose';
-import { quoteRicheseBetrayalInvoice, quoteRicheseNormalBetrayalInvoice, richeseBetrayalVetoesPurchase, requiredRicheseBetrayalResponders, richeseBetrayalEligibility, richeseBetrayalEvent, createRicheseBetrayalReceipt, validateRicheseBetrayalCursor, type RicheseBetrayalCursor, type RicheseBetrayalReceipt } from './nexus-richese-betrayal';
+import { quoteRicheseBetrayalInvoice, quoteRicheseNormalBetrayalInvoice, quoteRicheseKaramaBetrayalInvoice, richeseBetrayalVetoesPurchase, 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';
@@ -1199,6 +1200,10 @@ type KaramaUse =
| { kind: 'shipment'; recipient: string; card?: string }
| { kind: 'purchase' }
| { kind: 'auctionPayment' };
+type RichesePreCostDeclaration = { round: string; lot: string } & (
+ | { kind: 'ordinary'; owner: string; card: string; index: number }
+ | { kind: 'special'; intent: Extract }
+);
type PendingHomeworldShipment = HomeworldShipmentIntent & {
guildSecretEvent?: string;
guildNexusEvent?: string;
@@ -1377,6 +1382,8 @@ export type Game = {
} | {
/** The actual unpaid ordinary pool, never a fabricated Richese cache lot. */
normal: { round: string; lot: string; index: number };
+ } | {
+ preCost: RichesePreCostDeclaration;
}) | null;
/** Original normal-auction obligation; neutral acknowledgements precede funding. */
nexusEmperorPayment?: { sequence: number; current: string | null; completed: EmperorPaymentReceipt[] };
@@ -12722,6 +12729,16 @@ function richeseBetrayalResponders(g: Game): string[] {
})));
}
function richeseBetrayalSourceSignature(g: Game, frame: NonNullable): string {
+ if ('preCost' in frame) return JSON.stringify({
+ event:frame.receipt.event,preCost:frame.preCost,turn:g.turn,phase:g.phase,status:g.status,
+ round:g.richeseBidding,auction:g.auction,richeseAuction:g.richeseAuction,cache:g.richeseCache,
+ order:g.order,aid:g.aid,nexus:g.nexusCards,history:g.nexusEmperorPurchaseHistory,
+ players:g.players.map(p=>({id:p.id,faction:p.faction,ally:p.ally,hand:p.hand,
+ spice:p.spice,specialKaramaUsed:p.specialKaramaUsed})),
+ active:g.active,decision:g.decision,response:g.response,phaseOpening:g.phaseOpening,
+ pendingKarama:g.pendingKarama,truthtrance:g.truthtrance,sale:g.currentAuctionSale,
+ purchaseIncome:g.pendingRichesePurchaseIncome,
+ });
if ('normal' in frame) return JSON.stringify({
event:frame.receipt.event,normal:frame.normal,turn:g.turn,phase:g.phase,status:g.status,
round:g.richeseBidding,auction:g.auction,order:g.order,aid:g.aid,
@@ -12778,6 +12795,79 @@ function normalRicheseBetrayalPreflight(g: Game) {
currentAuctionContinuationQuote(g,{kind:'next'});
return invoice;
}
+function richesePreCostInvoice(g: Game, declaration: RichesePreCostDeclaration) {
+ requireRule(richeseBetrayalModeSupported(g) && g.status === 'playing',
+ 'This declared purchase needs the original Richese Nexus profile.');
+ const owner=declaration.kind === 'ordinary' ? declaration.owner : declaration.intent.owner;
+ const p=getPlayer(g,owner);
+ requireRule(p.faction === 'richese', 'Only the original Richese acquisition can be stopped.');
+ let card:Card, costCard:Card, sourceCards:Card[];
+ if (declaration.kind === 'ordinary') {
+ requireRule(!g.pendingKarama && !g.pendingKull && !g.pendingTreacheryDiscard &&
+ !g.pendingNexusEmperorPayment && !g.pendingNexusEmperorSellerPurchase,
+ 'Finish the already committed auction-card effect before another acquisition.');
+ normalKaramaAuction(g,p,false);
+ requireRule(g.auction!.index === declaration.index, 'The declared ordinary lot has changed.');
+ costCard=p.hand.find(c=>c.id===declaration.card)!;
+ requireRule(costCard, 'The unplayed activation Karama must remain held.');
+ const blocked=karamaSpendingBlock(g,p,costCard,'auction-free-purchase');
+ requireRule(!blocked,blocked ?? 'This Karama is unavailable.');
+ validateKaramaUse(g,p,{kind:'purchase'},costCard);
+ card=g.auction!.cards[declaration.index];sourceCards=[card];
+ } else {
+ const intent=declaration.intent;
+ requireRule(intent.turn===g.turn && intent.phase===g.phase,
+ 'The declared special acquisition belongs to another window.');
+ const prepared=prepareSpecialKaramaIntent(g,owner,specialKaramaAction(intent));
+ requireRule(prepared.kind==='richese' && JSON.stringify(prepared)===JSON.stringify(intent),
+ 'The original private special-purchase selection or funding has changed.');
+ costCard=p.hand.find(c=>c.id===intent.card)!;
+ card=g.richeseCache!.find(c=>c.id===intent.acquire)!;sourceCards=g.richeseCache!;
+ }
+ return nexusRule(()=>quoteRicheseKaramaBetrayalInvoice({players:g.players,owner,
+ source:declaration.kind==='ordinary'?'karama':'specialKarama',card,costCard,
+ ownerCards:p.hand,sourceCards,physicalCards:physicalTreacheryCards(g),
+ originalRecipient:byFaction(g,'emperor')?.id ?? null}));
+}
+/** Costed callbacks remain the original owner's voluntary declaration, even
+ * when the last neutral acknowledgement is made by another player. */
+function reconcileRichesePreCostPromises(g: Game, declaration: RichesePreCostDeclaration) {
+ const owner=declaration.kind==='ordinary'?declaration.owner:declaration.intent.owner;
+ const action:Action=declaration.kind==='ordinary'
+ ?{type:'card',mode:'purchase',card:declaration.card}:specialKaramaAction(declaration.intent);
+ reconcileBattlePromises(g,{actor:owner,action});
+ reconcileShipmentPromises(g,{actor:owner,action});
+ reconcileGroundMovementPromises(g,owner,action,g.phase);
+}
+function beginRichesePreCostBetrayal(g: Game, source:
+ {kind:'ordinary';owner:string;card:string;index:number} |
+ {kind:'special';intent:Extract}): boolean {
+ if (!g.richeseBetrayalPreview) return false;
+ const declaration:RichesePreCostDeclaration={...source,round:crypto.randomUUID(),lot:crypto.randomUUID()};
+ const invoice=richesePreCostInvoice(g,declaration),required=richeseBetrayalResponders(g);
+ if (!required.length) return false;
+ const owner=invoice.buyer;
+ if (liveShipmentPromises(g.shipmentPromises ?? [],owner,g.turn).length ||
+ g.battle?.truthPromises?.some(p=>p.player===owner && !p.released) ||
+ liveMovementPromises(g.movementPromises ?? [],owner,g.turn).some(p=>p.answer)) {
+ const projected=structuredClone(g);
+ if (source.kind==='ordinary') {
+ const p=getPlayer(projected,owner);
+ spendKarama(projected,p,p.hand.find(c=>c.id===source.card)!,{kind:'purchase'});
+ } else executeSpecialKaramaIntent(projected,source.intent);
+ reconcileRichesePreCostPromises(projected,declaration);
+ }
+ const cursor=g.richeseBetrayal!,sequence=cursor.sequence+1;
+ const receipt=nexusRule(()=>createRicheseBetrayalReceipt(g,{
+ event:richeseBetrayalEvent(g.turn,sequence,declaration.round,declaration.lot),
+ turn:g.turn,sequence,round:declaration.round,lot:declaration.lot,invoice,required,
+ passed:[],stage:'pending',actor:null,nexusDiscardIndex:null,
+ }));
+ const frame:NonNullable={receipt,preCost:declaration,signature:''};
+ frame.signature=richeseBetrayalSourceSignature(g,frame);
+ cursor.sequence=sequence;cursor.current=receipt.event;g.pendingRicheseBetrayal=frame;
+ return true;
+}
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 &&
@@ -12837,7 +12927,17 @@ function richeseBetrayalIntegrity(g: Game): void {
nexusCardsIntegrity(g);
nexusRule(() => validateRicheseBetrayalCursor(g,cursor,frame?.receipt ?? null));
if (frame) {
- if ('normal' in frame) {
+ if ('preCost' in frame) {
+ const declaration=frame.preCost,invoice=richesePreCostInvoice(g,declaration);
+ const keys=declaration.kind==='ordinary'?'card,index,kind,lot,owner,round':'intent,kind,lot,round';
+ requireRule(Object.keys(frame).sort().join(',')==='preCost,receipt,signature' &&
+ Object.keys(declaration).sort().join(',')===keys &&
+ declaration.round===frame.receipt.round && declaration.lot===frame.receipt.lot &&
+ frame.receipt.turn===g.turn && JSON.stringify(invoice)===JSON.stringify(frame.receipt.invoice) &&
+ frame.signature===richeseBetrayalSourceSignature(g,frame) &&
+ JSON.stringify(frame.receipt.required)===JSON.stringify(richeseBetrayalResponders(g)),
+ 'The intended Karama purchase lost its unplayed costs, selected card or original continuation.');
+ } else if ('normal' in frame) {
const invoice=normalRicheseBetrayalPreflight(g);
requireRule(Object.keys(frame).sort().join(',') === 'normal,receipt,signature' &&
Object.keys(frame.normal).sort().join(',') === 'index,lot,round' &&
@@ -12864,8 +12964,10 @@ function richeseBetrayalIntegrity(g: Game): void {
}
}
const sale = g.currentAuctionSale;
- const completed = cursor.completed.at(-1);
- if (completed && completed.invoice.source !== 'normal' && completed.lot === g.richeseAuction?.event) {
+ const completed = cursor.completed.find(receipt=>
+ (receipt.invoice.source==='cache' || receipt.invoice.source==='blackMarket') &&
+ receipt.lot===g.richeseAuction?.event);
+ if (completed) {
const waiting = g.pendingNexusEmperorSellerPurchase;
requireRule(g.status === 'playing' && g.phase === 3 && completed.turn === g.turn &&
completed.round === g.richeseBidding?.event &&
@@ -12894,7 +12996,7 @@ function richeseBetrayalIntegrity(g: Game): void {
}
}
if (sale?.recipient) {
- const receipt = cursor.completed.at(-1);
+ const receipt = completed;
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 &&
@@ -12966,6 +13068,30 @@ function decideRicheseBetrayal(g: Game, p: Player, action: Action): void {
g.richeseBetrayal!.completed.push(next);
g.richeseBetrayal!.current = null;
g.pendingRicheseBetrayal = null;
+ if ('preCost' in frame) {
+ const declaration=frame.preCost;
+ if (use && richeseBetrayalVetoesPurchase(receipt.invoice)) {
+ if (declaration.kind==='ordinary') {
+ const next=normalAuctionRetirementQuote(g);
+ g.discard.push(g.auction!.cards[g.auction!.index]);
+ g.response=null;g.decision=null;
+ commitNormalAuctionRetirement(g,next);
+ } else {
+ const index=g.richeseCache!.findIndex(card=>card.id===declaration.intent.acquire);
+ requireRule(index>=0,'The privately selected card must remain in its original cache.');
+ g.discard.push(...g.richeseCache!.splice(index,1));
+ }
+ log(g,`${p.name} spent Richese Nexus Betrayal and stopped the intended Karama purchase. The selected card was discarded privately; its activation costs remain unspent.`);
+ } else {
+ if (use) log(g,`${p.name} spent a committed Richese Nexus attempt. The intended Karama acquisition continues without card inspection.`);
+ if (declaration.kind==='ordinary') {
+ const buyer=getPlayer(g,declaration.owner);
+ spendKarama(g,buyer,buyer.hand.find(card=>card.id===declaration.card)!,{kind:'purchase'});
+ } else executeSpecialKaramaIntent(g,declaration.intent);
+ reconcileRichesePreCostPromises(g,declaration);
+ }
+ return;
+ }
if ('normal' in frame) {
// Cost precedes the private family test. A miss cannot be a free oracle.
if (use && richeseBetrayalVetoesPurchase(receipt.invoice)) {
@@ -14199,18 +14325,30 @@ function commitAuctionContinuation(g: Game, quote: AuctionContinuationQuote,bure
offerOccupiedBonus(g, g.response);
}
else if (next.kind === 'richeseEnd') finishRicheseLot(g);
- else {
- g.currentAuctionSale = null;
- if (next.kind === 'normalEnd') {
- g.deck = [...next.returned, ...g.deck];
- g.auction = null;
- finishNormalBidding(g);
- } else {
- g.auction = next.auction;
- g.active = next.active;
- if (next.offer?.kind === 'ixTechnology') g.decision = next.offer;
- else if (next.offer) offerAuctionPeek(g);
- }
+ else commitNormalAuctionRetirement(g,next);
+}
+function normalAuctionRetirementQuote(g: Game) {
+ try {
+ return quoteNormalAuctionRetirement({turn:g.turn,advanced:g.advanced,
+ players:g.players.map(p=>({...p,handLimit:handLimit(p)})),order:g.order,auction:g.auction,
+ richeseBidding:g.richeseBidding,richeseCacheCount:g.richeseCache?.length,
+ ixTechnologyTurn:g.ixTechnologyTurn,physicalCards:physicalTreacheryCards(g)});
+ } catch(error) {
+ if(error instanceof NormalAuctionRetirementError)throw new RuleError(error.message);
+ throw error;
+ }
+}
+function commitNormalAuctionRetirement(g: Game, next: NormalAuctionRetirementQuote) {
+ g.currentAuctionSale = null;
+ if (next.kind === 'normalEnd') {
+ g.deck = [...next.returned, ...g.deck];
+ g.auction = null;
+ finishNormalBidding(g);
+ } else {
+ g.auction = next.auction;
+ g.active = next.active;
+ if (next.offer?.kind === 'ixTechnology') g.decision = next.offer;
+ else if (next.offer) offerAuctionPeek(g);
}
}
function continueAuctionSale(g: Game, free: boolean) {
@@ -33081,6 +33219,7 @@ function resolveSpecialKaramaEffect(g: Game, p: Player, card: Card, intent: Spec
}
function specialKarama(g: Game, p: Player, action: Action) {
const intent = prepareSpecialKaramaIntent(g, p.id, action);
+ if (intent.kind==='richese' && beginRichesePreCostBetrayal(g,{kind:'special',intent})) return;
const card = p.hand.find(card => card.id === intent.card)!;
if (!beginKullAttempt(g, p, card, { kind: 'special', use: intent }))
executeSpecialKaramaIntent(g, intent);
@@ -37592,6 +37731,8 @@ function applyActionInner(
g.phase === 3 && g.auction && p.hand.length < handLimit(p),
'You may take the current auction card only when eligible to bid.',
);
+ if (p.faction==='richese' && beginRichesePreCostBetrayal(g,
+ {kind:'ordinary',owner:p.id,card:c.id,index:g.auction!.index})) return g;
spendKarama(g, p, c, { kind: 'purchase' });
return g;
}
diff --git a/game/nexus-richese-betrayal.ts b/game/nexus-richese-betrayal.ts
index cba85877..3df2acf8 100644
--- a/game/nexus-richese-betrayal.ts
+++ b/game/nexus-richese-betrayal.ts
@@ -9,7 +9,7 @@ export type RicheseBetrayalInvoice = {
kind: 'purchase' | 'sale';
target: string;
buyer: string;
- source: 'cache' | 'blackMarket' | 'normal';
+ source: 'cache' | 'blackMarket' | 'normal' | 'karama' | 'specialKarama';
price: number;
ownPayment: number;
allyPayment: number;
@@ -88,19 +88,24 @@ function validateInvoice(players: readonly NexusPlayer[], invoice: RicheseBetray
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', 'normal'].includes(invoice.source) &&
- whole(invoice.price) && invoice.price > 0 && whole(invoice.ownPayment) && whole(invoice.allyPayment) &&
+ ['cache', 'blackMarket', 'normal', 'karama', 'specialKarama'].includes(invoice.source) &&
+ whole(invoice.price) && (invoice.source === 'karama' ? invoice.price === 0 :
+ invoice.source === 'specialKarama' ? invoice.price === 3 : 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.');
+ validCard(invoice.card), 'Richese Betrayal needs its original source price, exact funding split and physical card.');
if (invoice.kind === 'purchase') {
- requireBetrayal((invoice.source === 'normal' || invoice.source === 'cache' && richeseCardDefinition(invoice.card)) &&
+ requireBetrayal((['normal', 'karama'].includes(invoice.source) ||
+ ['cache', 'specialKarama'].includes(invoice.source) && richeseCardDefinition(invoice.card)) &&
buyer.id === target.id &&
+ (invoice.source !== 'karama' && invoice.source !== 'specialKarama' ||
+ invoice.allyPayment === 0 && invoice.donor === null) &&
invoice.originalRecipient === (players.find(p => p.faction === 'emperor')?.id ?? null),
- 'Richese self-veto requires an original cache or normal purchase paid to Emperor or bank.');
+ 'Richese self-veto requires its original native purchase and payment recipient.');
} else {
- requireBetrayal(invoice.kind === 'sale' && invoice.source !== 'normal' &&
+ requireBetrayal(invoice.kind === 'sale' && ['cache', 'blackMarket'].includes(invoice.source) &&
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.');
@@ -184,11 +189,50 @@ export function quoteRicheseNormalBetrayalInvoice(input: {
return invoice;
}
-/** Evaluate only after the physical Nexus has been committed. A normal-lot
- * miss still records a used counter; it grants no earlier face inspection. */
+/** Capture a selected, still unplayed Karama purchase. The caller owns native
+ * role, funding and once-per-game legality; this quote owns distinct physical
+ * cost/target custody and the original invoice. */
+export function quoteRicheseKaramaBetrayalInvoice(input: {
+ players: readonly NexusPlayer[];
+ owner: string;
+ source: 'karama' | 'specialKarama';
+ card: Card;
+ costCard: Card;
+ ownerCards: readonly Card[];
+ sourceCards: readonly Card[];
+ physicalCards: readonly Card[];
+ originalRecipient: string | null;
+}): RicheseBetrayalInvoice {
+ requireBetrayal(plain(input), 'Richese Betrayal needs an original Karama purchase.');
+ validatePlayers(input.players);
+ requireBetrayal(text(input.owner) && input.players.some(player => player.id === input.owner && player.faction === 'richese') &&
+ ['karama', 'specialKarama'].includes(input.source) && validCard(input.card) && validCard(input.costCard) &&
+ input.card.id !== input.costCard.id,
+ 'Richese Betrayal needs its native owner and distinct physical purchase and Karama cost.');
+ for (const [cards, card] of [
+ [input.ownerCards, input.costCard], [input.sourceCards, input.card],
+ [input.physicalCards, input.costCard], [input.physicalCards, input.card],
+ ] as const) {
+ requireBetrayal(Array.isArray(cards) && cards.every(validCard), 'Richese Betrayal needs physical source custody.');
+ const matches = cards.filter(candidate => candidate.id === card.id);
+ requireBetrayal(matches.length === 1 && sameCard(matches[0], card),
+ 'Richese Betrayal has lost or duplicated its exact purchase or Karama cost card.');
+ }
+ const price = input.source === 'karama' ? 0 : 3;
+ const invoice: RicheseBetrayalInvoice = {
+ kind: 'purchase', target: input.owner, buyer: input.owner, source: input.source,
+ price, ownPayment: price, allyPayment: 0, donor: null,
+ originalRecipient: input.originalRecipient, card: { ...input.card },
+ };
+ validateInvoice(input.players, invoice);
+ return invoice;
+}
+
+/** Evaluate only after the physical Nexus has been committed. A concealed
+ * ordinary-lot miss still records a used counter without prior inspection. */
export function richeseBetrayalVetoesPurchase(invoice: RicheseBetrayalInvoice): boolean {
return invoice.kind === 'purchase' &&
- (invoice.source !== 'normal' || !!richeseCardDefinition(invoice.card));
+ (!['normal', 'karama'].includes(invoice.source) || !!richeseCardDefinition(invoice.card));
}
/** Public held-presence only: private Nexus identities never affect timing. */
diff --git a/game/normal-auction-retirement.ts b/game/normal-auction-retirement.ts
new file mode 100644
index 00000000..c94bf284
--- /dev/null
+++ b/game/normal-auction-retirement.ts
@@ -0,0 +1,103 @@
+import type { AuctionContinuationInput, AuctionContinuationQuote } from './auction-continuation-quote';
+
+export type NormalAuctionRetirementInput = Pick;
+export type NormalAuctionRetirementQuote = Extract;
+
+export class NormalAuctionRetirementError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'NormalAuctionRetirementError';
+ }
+}
+function requireRetirement(value: unknown, message: string): asserts value {
+ if (!value) throw new NormalAuctionRetirementError(message);
+}
+const text = (value: unknown): value is string => typeof value === 'string' && value.length > 0;
+const count = (value: unknown): value is number => Number.isSafeInteger(value) && (value as number) >= 0;
+const ids = (value: unknown): value is string[] =>
+ Array.isArray(value) && value.every(text) && new Set(value).size === value.length;
+const object = (value: unknown): value is Record =>
+ !!value && typeof value === 'object' && !Array.isArray(value);
+
+/** Retire one original ordinary lot, bought or unbought. No winner, sale,
+ * payment, hand delivery or faction income is manufactured by this quote. */
+export function quoteNormalAuctionRetirement(input: NormalAuctionRetirementInput): NormalAuctionRetirementQuote {
+ try {
+ return calculate(input);
+ } catch (error) {
+ if (error instanceof NormalAuctionRetirementError) throw error;
+ throw new NormalAuctionRetirementError(error instanceof Error ? error.message : 'Malformed normal auction retirement.');
+ }
+}
+function calculate(input: NormalAuctionRetirementInput): NormalAuctionRetirementQuote {
+ requireRetirement(count(input.turn) && input.turn > 0 && typeof input.advanced === 'boolean',
+ 'Normal auction retirement needs its current turn and rules mode.');
+ requireRetirement(Array.isArray(input.players) && ids(input.players.map(player => player.id)) &&
+ input.players.every(player => Array.isArray(player.hand) &&
+ player.hand.every((card: unknown) => object(card) && text(card.id)) &&
+ count(player.handLimit) && player.handLimit > 0),
+ 'Auction seats or hand counts are malformed.');
+ const seated = (id: string) => input.players.find(player => player.id === id);
+ requireRetirement(ids(input.order) && input.order.length > 0 && input.order.every(id => seated(id)),
+ 'The auction needs a nonempty unique seated order.');
+ const a = input.auction;
+ requireRetirement(a && Array.isArray(a.cards) && count(a.index) && a.index < a.cards.length &&
+ a.cards.every(card => object(card) && text(card.id) && text(card.name) && text(card.kind)),
+ 'The normal auction has an invalid current card or index.');
+ requireRetirement(count(a.opener) && a.opener < input.order.length && input.order.includes(a.active) &&
+ ids(a.passed) && a.passed.every(id => input.order.includes(id)) && count(a.bid) &&
+ (a.allyPayment === undefined || count(a.allyPayment) && a.allyPayment <= a.bid) &&
+ (a.bidder === null ? a.bid === 0 : input.order.includes(a.bidder)),
+ 'The normal auction has an invalid opener, bidder or bid context.');
+ // Earlier sold entries are historical aliases. Only the remaining pool must
+ // be unique, including against the original lot that is being retired.
+ const unsold = a.cards.slice(a.index + 1).map(card => card.id);
+ requireRetirement(ids(unsold) && !unsold.includes(a.cards[a.index].id),
+ 'The unsold auction pool duplicates its current physical card.');
+ requireRetirement(Array.isArray(input.physicalCards) && unsold.every(id =>
+ input.physicalCards.filter(card => card?.id === id).length === 1),
+ 'The unsold auction pool needs unique physical card custody.');
+ const round = input.richeseBidding;
+ if (round) requireRetirement(round.turn === input.turn && round.stage === 'normal' &&
+ seated(round.owner)?.faction === 'richese' && ['first', 'last', null].includes(round.position) &&
+ typeof round.cacheCanceled === 'boolean',
+ 'The normal pool has a stale Richese round.');
+
+ const index = a.index + 1;
+ const able = input.order.filter(id => {
+ const player = seated(id)!;
+ return player.hand.length < player.handLimit;
+ });
+ if (index === a.cards.length || !able.length) {
+ const activeRound = round?.turn === input.turn && round.stage === 'normal';
+ // User ruling 7 October 2026: a last cache lot needs physical cache stock
+ // and an uncanceled compulsory auction.
+ const cacheLast = !!activeRound && round.position === 'last' && !round.cacheCanceled &&
+ (input.richeseCacheCount ?? 0) > 0;
+ return {
+ kind: 'normalEnd', returned: structuredClone(a.cards.slice(index)),
+ after: cacheLast ? 'richeseCache' : 'phase', completeRound: !!activeRound && !cacheLast,
+ };
+ }
+ let opener = a.opener;
+ do {
+ opener = (opener + 1) % input.order.length;
+ } while (!able.includes(input.order[opener]));
+ const active = input.order[opener];
+ const ix = input.players.find(player => player.faction === 'ixians');
+ const atreides = input.players.find(player => player.faction === 'atreides');
+ const offer = input.advanced && ix && ix.hand.length > 0 && input.ixTechnologyTurn !== input.turn
+ ? { kind: 'ixTechnology' as const, player: ix.id }
+ : atreides ? { kind: 'atreidesAuction' as const, owner: atreides.id, passed: [] } : null;
+ const nextAuction = structuredClone(a);
+ delete nextAuction.saphoOrder;
+ return {
+ kind: 'normalLot', auction: {
+ ...nextAuction, index, bid: 0, allyPayment: 0, bidder: null,
+ passed: [], opener, active, peekKnown: false,
+ }, active, offer,
+ };
+}
diff --git a/game/reference.ts b/game/reference.ts
index 0c30cdf1..87253f8f 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -591,9 +591,9 @@ export const RULE_TOPICS: RuleTopic[] = [
steps: [
'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.',
- 'With Emperor absent, an actual unallied Emperor Nexus holder may use the private joint option after proving three own uncommitted spice. Original Karama and Nexus both spend at purchase commit, one exact cache card enters your hand and native once-use/capacity/reservations remain. No bank money, reimbursement, ally bonus, fake auction or automatic public pause. This purchase composition is visibly provisional; ordinary paid use remains available.',
+ 'With Emperor absent, an actual unallied Emperor Nexus holder may use the private joint option after proving three own uncommitted spice. Original Karama and Nexus both spend at purchase commit, one exact cache card enters your hand and native once-use/capacity/reservations remain. No bank money, reimbursement, ally bonus or fake auction is created. The separate Betrayal preview may pause before those costs. This purchase composition is visibly provisional; ordinary paid use remains available.',
'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 separate Richese Nexus Betrayal preview also pauses this intended private acquisition before its costs. A successful veto discards only the selected cache card; Karama, three spice and once-use remain unspent, including an intended joint Emperor Nexus. This order is explicitly provisional composition. Ordinary Karama immunity does not automatically grant Nexus immunity.',
'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 now has a visibly provisional exchange policy: spend the held Karama and acquire one cache card, leaving the final hand within its actual limit. This is not a publisher timing clarification or numerical user ruling. Buying the final cached card is legal under the resolved exhausted-cache auction rule; subsequent Bidding restores the ordinary pool. Reserved cards, over-limit hands and pending commitments remain ineligible.',
'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.',
@@ -5016,7 +5016,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'At an eligible original classic/native Nexus normal bank-auction winner payment, prove the positive final bid from your own spice and spend Emperor Nexus to receive the lot without losing spice. Every winner sees the same payment confirmation; the provisional privacy recommendation remains. Canonical native module envelopes preserve original card/capacity, bonus and next-lot inspection; ordinary spice or legal Karama remains available. No bank subsidy or Richese cache/seller route is invented. Strict old physical revival admission is separate and never broadens.',
'Original Richese cache and other-buyer Advanced Black Market offer Pay or eligible Emperor Nexus Use after original Richese Betrayal. The provisional seller-unpaid policy retains your full personally affordable price without bank reimbursement. One original lot, native Harkonnen bonus and source successor continue; legal self-cache is included. Already-held Black Market paid retention supplies no acquisition/free-seller source, so it does not invent this confirmation or spend a Nexus. Original self-purchase forced discard cannot consume this Nexus or acquire a card.',
'A committed Richese Ambassador trigger now offers its original beneficiary Pay or eligible Emperor Nexus Use, with no new decline. Under the visible provisional purchase composition, prove and keep three own spice while drawing one original deck card; no bank payment, reimbursement or fake auction is made. Original private inability, native bonus and entrant movement/worm continuation remain. Allies cannot Use; actual forming-alliance Nexus disposal stays original.',
- 'Original Advanced Richese special Karama has an eligible private joint option: spend the held Karama plus Emperor Nexus at native purchase commit after proving three own uncommitted spice, keep that spice and acquire one exact unreserved cache card. Native once-use, full-hand/final-cache and enclosing controls remain; no bank money, ally bonus, fake auction or automatic public pause. Ordinary Pay3 remains available. This source composition is visibly provisional, not immunity or broader Kull admission.',
+ 'Original Advanced Richese special Karama has an eligible private joint option: spend the held Karama plus Emperor Nexus at native purchase commit after proving three own uncommitted spice, keep that spice and acquire one exact unreserved cache card. Native once-use, full-hand/final-cache and enclosing controls remain; no bank money, ally bonus or fake auction is created. An explicit Richese Betrayal preview pauses before either cost; veto keeps both cards and once-use, all-pass executes the same joint intent. Ordinary Pay3 remains available. These compositions are visibly provisional, not immunity or broader Kull admission.',
],
related: ['nexus-cards', 'revival'],
checklist: [
@@ -5037,17 +5037,19 @@ export const RULE_TOPICS: RuleTopic[] = [
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. Use commits 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.',
+ 'The opportunity opens after a valid winning quote or prepared Karama acquisition, before payment, activating-card spend, source removal or delivery. It binds the original card, buyer, price, funding selection and native continuation.',
'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.',
'A positive Richese win in the ordinary auction also opens the neutral response before payment choice, even when the face is concealed. Under the provisional blind-attempt convention, commit your Richese Nexus first. If the reserved card is a Richese-family card, it is discarded without payment, delivery or purchase bonus. Otherwise the Nexus remains spent and the original payment choice continues. There is no pre-spend family check, inspection or refund. The exact discarded face stays private.',
+ 'An immediate ordinary Karama acquisition opens the same blind attempt before its held activation card is played. On a successful veto, that Karama stays held and the unbought lot retires once. On a miss or all-pass, the exact original Karama spends and Richese acquires the lot free once. A competing bid is not charged or awarded.',
+ 'The Advanced private special purchase also pauses before costs, with no selected face or private funding disclosed. Its selected cache card is necessarily Richese-family. A veto discards only that card: Karama, three spice and once-use stay unspent. An intended joint Emperor Nexus stays held too. All-pass executes the original paid or joint intent once, including separate native Emperor income and any suspended parent response. Pre-cost ordering is visibly provisional, not a printed refund ruling.',
'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 purchase resumes its native payment choice and settles once with its original 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/ordinary source and winning price. It does not show payer resources, ally balances, rival hands, secret Nexus identities or the saved private receipt. Read your own unavailable reason rather than guessing Use from a held-card face alone.',
+ 'The panel shows the public Richese target, buyer and source. Auctions show their winning price; ordinary Karama shows an intended free acquisition and private special Karama its nominal three-spice price. Private funding stays concealed. No payer resources, ally balances, rival hands, secret Nexus identities or saved receipt are shown.',
'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.',
- 'The normal-lot blind-attempt rule is online composition, not a printed general right to discard a Nexus card at any time. Its normal winning-bid boundary precedes the existing spice/Karama payment choice. Proactive immediate Karama acquisition and the private Advanced special-Karama acquisition retain their separate activating-card and once-use boundaries; this prototype does not certify those producers.',
+ 'The normal-lot blind-attempt rule is online composition, not a printed general right to discard a Nexus card at any time. Its winning-bid boundary precedes the spice/Karama chooser; immediate ordinary and private special acquisitions retain their own exact unplayed activating-card and once-use boundaries.',
'Existing cache-count, ordering and special-acquisition boundaries remain. Already-held Black Market paid retention supplies neither a cache acquisition nor seller income, so no veto/diversion invoice is invented. Unsupported counters or any-time overlays must not enter this saved purchase boundary. Finish a real response before underlying bidding or other actions can change its reserved card or funding.',
'Refreshing resumes the same event, passes and original auction continuation. Saved pending and completed history must retain exact physical source and once-only payment without rerolls, raw-action replay or requiring old cards to remain forever in discard. Stale or foreign event submissions reject rather than selecting another lot.',
'All four AI difficulties use the same private legal Use/public Pass projection and pass blind ordinary attempts while strategic play is deferred. Prior cache/Black Market SQL and phone-browser evidence does not certify the new normal-lot source. Full Richese/Nexus games, wider compositions and deployed acceptance remain open.',
@@ -5055,11 +5057,11 @@ export const RULE_TOPICS: RuleTopic[] = [
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: 'Public self-cache veto, original ordinary winning-bid blind attempt and other-buyer cache/Black Market recipient override bind actual sources and continuations. Ordinary attempts commit Nexus before family outcome; success skips payment/acquisition and advances once, pass/miss resumes the original chooser. Proactive/special acquisition and wider responses remain unfinished.', evidence: ['game/nexus-richese-betrayal.ts', 'game/engine.ts', 'game/auction-continuation-quote.ts'] },
+ { area: 'Implementation', status: 'Partial', detail: 'Self-cache, ordinary winning-bid and immediate Karama veto, private special pre-cost veto and other-buyer cache/Black Market recipient override bind actual sources. Ordinary blind misses spend Nexus then resume native purchase. Successful pre-cost veto retains original costs; private joint funding and parent income resume once on pass. Wider profiles/responses remain unfinished.', evidence: ['game/nexus-richese-betrayal.ts', 'game/engine.ts', 'game/normal-auction-retirement.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: 'New ordinary source has separate invoice, actual auction-continuation and connected controls/minimal-profile cases with a labelled controlled recycled-card fixture. Historical cache/Black Market full-suite, SQL and phone-browser evidence remains dated legacy scope; it does not prove normal-lot recovery or deployed acceptance. Full-family, broader combinations and final assurance remain open.', evidence: ['tests/nexus-richese-betrayal-normal.test.ts', 'tests/richese-normal-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-normal-controls.test.tsx', 'tests/fixture-richese-normal-betrayal.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'docs/NEXUS_RICHESE_RULES.md'] },
+ { area: 'Verification', status: 'Partial', detail: 'Focused invoice, unbought retirement, real producer and controls/profile cases cover ordinary winning bids, immediate Karama and private special/joint intent. Controlled ordinary recycling is labelled; private sources use native draws. Historical SQL/phone evidence remains dated legacy scope. Broader combinations, final assurance and deployed acceptance remain open.', evidence: ['tests/nexus-richese-betrayal-karama.test.ts', 'tests/normal-auction-retirement.test.ts', 'tests/richese-karama-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-karama-controls.test.tsx', 'tests/fixture-richese-karama-betrayal.ts', 'tests/richese-normal-betrayal-engine.test.ts', 'docs/NEXUS_RICHESE_RULES.md'] },
],
},
{
diff --git a/tests/fixture-richese-karama-betrayal.ts b/tests/fixture-richese-karama-betrayal.ts
new file mode 100644
index 00000000..b04d82e2
--- /dev/null
+++ b/tests/fixture-richese-karama-betrayal.ts
@@ -0,0 +1,222 @@
+import assert from 'node:assert/strict';
+import { viewGame, type Action, type Game } from '../game/engine';
+import {
+ createRicheseNormalBetrayalFixture,
+ actRicheseNormalBetrayal,
+ advanceRicheseNormalBetrayal,
+ nextRicheseNormalBetrayal,
+ passRicheseNormalBetrayal,
+ finishRicheseNormalBetrayal,
+ richeseNormalBetrayalClean,
+ richeseNormalBetrayalPlayer,
+ richeseNormalBetrayalInventory,
+ richeseNormalBetrayalNexusInventory,
+ type RicheseNormalBetrayalStep,
+} from './fixture-richese-normal-betrayal';
+import {
+ createNexusEmperorSellerFixture, nextNexusEmperorSellerStep,
+} from './fixture-nexus-emperor-seller';
+
+export type RicheseKaramaBetrayalStep = RicheseNormalBetrayalStep;
+export const actRicheseKaramaBetrayal = actRicheseNormalBetrayal;
+export const nextRicheseKaramaBetrayal = nextRicheseNormalBetrayal;
+export const advanceRicheseKaramaBetrayal = advanceRicheseNormalBetrayal;
+export const passRicheseKaramaBetrayal = passRicheseNormalBetrayal;
+export const finishRicheseKaramaBetrayal = finishRicheseNormalBetrayal;
+export const richeseKaramaBetrayalClean = richeseNormalBetrayalClean;
+export const richeseKaramaBetrayalPlayer = richeseNormalBetrayalPlayer;
+export const richeseKaramaBetrayalInventory = richeseNormalBetrayalInventory;
+export const richeseKaramaBetrayalNexusInventory = richeseNormalBetrayalNexusInventory;
+
+export function commitRicheseKaramaBetrayal(game: Game, fixture: { target: string; action: Action },
+ actions: RicheseKaramaBetrayalStep[] = []) {
+ const before = structuredClone(game), start = actions.length;
+ game = actRicheseKaramaBetrayal(game, fixture.target, fixture.action, actions);
+ return { game, before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
+}
+
+/** Both inputs fork real earlier snapshots of the original source programme.
+ * Ordinary uses its LABELLED recycled first normal lot before bids/cost.
+ * Quiet special branches before any controlled recycle; its selected cache
+ * card is still native original custody. No paused counter/cost is installed. */
+export function createRicheseKaramaBetrayalFixture(options: {
+ origin?: 'ordinary' | 'special'; advanced?: boolean; richeseCard?: boolean;
+ parent?: 'quiet' | 'emperorIncome'; holderNexus?: 'richese' | 'guild'; otherBidder?: boolean;
+} = {}) {
+ const origin = options.origin ?? 'ordinary', parent = options.parent ?? 'quiet';
+ const advanced = options.advanced ?? origin === 'special';
+ assert.ok(origin !== 'special' || advanced, 'Native private Richese special Karama requires Advanced');
+ assert.ok(parent === 'quiet' || origin === 'special', 'The incoming Emperor response belongs to the native special branch');
+ const f = createRicheseNormalBetrayalFixture({ advanced, richeseCard: options.richeseCard,
+ karama: true, holderNexus: options.holderNexus, allyPayment: parent === 'emperorIncome' ? 2 : 0 });
+ assert.ok(f.karama);
+ let game: Game, actions: RicheseKaramaBetrayalStep[];
+ if (origin === 'ordinary') {
+ game = structuredClone(f.beforeBids);
+ actions = structuredClone(f.actions.slice(0, f.actions.length - f.bidding.steps.length));
+ if (options.otherBidder) {
+ for (let count = 0; game.auction!.active !== f.holder && count < game.players.length; count++)
+ game = actRicheseKaramaBetrayal(game, game.auction!.active, { type: 'passBid' }, actions);
+ assert.equal(game.auction!.active, f.holder);
+ game = actRicheseKaramaBetrayal(game, f.holder, { type: 'bid', amount: 1, allyPayment: 0 }, actions);
+ }
+ } else if (parent === 'quiet') {
+ // Fork the actual final Nexus draw. Repeating only its normal phase-opening
+ // controls earns this branch's quiet Charity boundary and exact action log;
+ // no later controlled main-deck recycle or normal bid belongs to this source.
+ const lastDraw = f.nexusDraws.at(-1); assert.ok(lastDraw);
+ const drawStep = lastDraw.steps.at(-1); assert.ok(drawStep);
+ const drawAt = f.actions.findIndex(step => step.actor === drawStep.actor &&
+ JSON.stringify(step.action) === JSON.stringify(drawStep.action)); assert.ok(drawAt >= 0);
+ game = structuredClone(lastDraw.after);
+ actions = structuredClone(f.actions.slice(0, drawAt + 1));
+ game = advanceRicheseKaramaBetrayal(game, state => state.turn === 2 && state.phase === 2 &&
+ richeseKaramaBetrayalClean(state), actions);
+ assert.deepEqual(richeseKaramaBetrayalInventory(game), richeseKaramaBetrayalInventory(f.beforeRecycle));
+ assert.deepEqual(game.players.map(player => ({ id: player.id, hand: player.hand, spice: player.spice })),
+ f.beforeRecycle.players.map(player => ({ id: player.id, hand: player.hand, spice: player.spice })));
+ } else {
+ actions = structuredClone(f.actions);
+ game = passRicheseKaramaBetrayal(structuredClone(f.game), actions);
+ assert.equal(game.decision?.kind, 'auctionPayment');
+ game = actRicheseKaramaBetrayal(game, f.target, { type: 'decision', karama: false }, actions);
+ assert.equal(game.response?.kind, 'emperorIncome');
+ assert.equal(game.currentAuctionSale?.amount ?? game.auction?.bid, 3);
+ assert.equal(richeseKaramaBetrayalPlayer(game, f.target).spice, 4);
+ }
+ const cost = richeseKaramaBetrayalPlayer(game, f.target).hand.find(card => card.id === f.karama); assert.ok(cost);
+ assert.equal(cost.effect, 'karama');
+ assert.equal(richeseKaramaBetrayalPlayer(game, f.holder).ally, null);
+ assert.equal(game.nexusCards!.cards!.hands[f.holder], options.holderNexus ?? 'richese');
+ const chosen = origin === 'special'
+ ? game.richeseCache!.find(card => card.id === 'richese-stone-burner')
+ : game.auction!.cards[game.auction!.index];
+ assert.ok(chosen); assert.notEqual(chosen.id, cost.id);
+ if (origin === 'ordinary') {
+ assert.equal(game.auction!.bid, options.otherBidder ? 1 : 0);
+ assert.equal(game.auction!.bidder, options.otherBidder ? f.holder : null);
+ assert.equal(chosen.id, f.card);
+ } else {
+ const offer = viewGame(game, f.target).richeseSpecialKarama;
+ assert.ok(offer && !offer.blocked && offer.cards.some(card => card.id === chosen.id));
+ assert.ok(!richeseKaramaBetrayalPlayer(game, f.target).specialKaramaUsed);
+ }
+ assert.ok(!game.pendingRicheseBetrayal);
+ assert.deepEqual(richeseKaramaBetrayalInventory(game), f.originalInventory);
+ assert.deepEqual(richeseKaramaBetrayalNexusInventory(game), f.originalNexusInventory);
+ const action: Action = origin === 'ordinary'
+ ? { type: 'card', mode: 'purchase', card: cost.id }
+ : { type: 'card', mode: 'special', card: cost.id, acquire: chosen.id };
+ const source = structuredClone(game);
+ return {
+ game, source, beforeCost: source, origin, sourceKind: origin === 'ordinary' ? 'karama' : 'specialKarama',
+ parent, advanced, otherBidder: options.otherBidder ?? false,
+ target: f.target, buyer: f.target, richese: f.target, owner: f.target,
+ holder: f.holder, emperor: f.emperor, ally: f.ally,
+ card: chosen.id, acquire: chosen.id, purchaseCard: structuredClone(chosen),
+ karama: cost.id, costCard: structuredClone(cost), action, activationAction: action,
+ originalResponse: structuredClone(game.response), originalDecision: structuredClone(game.decision),
+ originalInventory: f.originalInventory, originalCards: f.originalInventory,
+ originalNexusInventory: f.originalNexusInventory,
+ controlledRecycle: origin === 'ordinary' || parent === 'emperorIncome',
+ recycle: origin === 'ordinary' || parent === 'emperorIncome' ? f.recycle : null,
+ parentFixture: f, actions, steps: actions, sourceActionCount: actions.length,
+ producerActionCount: f.sourceActionCount,
+ staging: [
+ 'Borrow the original Richese/CHOAM/Emperor/Atreides audit source with real main35/cache10, original base Karama dealt to Richese, actual cache Karama retention/first normal Shield purchase, later native Worm/alliance and actual CHOAM-held Richese Nexus. No dealt hand, wallet, alliance, force, phase, current reaction or Karama receipt is installed.',
+ origin === 'ordinary'
+ ? 'Ordinary Karama source forks exact beforeBids: the actual first normal lot has zero bid/no bidder by default; optional otherBidder really lets CHOAM bid one before the declaration. The original base cost Karama is still held. The same explicitly CONTROLLED original Richese-Karama discard-to-unopened-main recycle supplies the family target; ordinary MISS instead uses the existing selected unopened ordinary face. Native card mode purchase is still undeclared.'
+ : parent === 'quiet'
+ ? 'Private special source forks the actual final Nexus draw and completes only its original phase-opening controls to quiet Charity2, matching the earlier beforeRecycle physical hands/balances. No controlled main-deck recycle or later auction belongs to this source. The actual remaining Stone Burner cache card, three own spice and original base Karama remain unspent.'
+ : 'Native parent source really passes the prior normal Betrayal and pays the actual three-spice winning bid using one own plus two actually pledged ally spice. It stops with Richese four spice and the original EmperorIncome3 still pending. That prior normal lot retains its explicitly controlled recycled target. The new private cache purchase is undeclared; its original response must survive either result.',
+ 'This four-seat branch does not claim a joint Emperor-Nexus special source: its original roster contains Emperor, while the existing joint-purchase profile explicitly requires Emperor absent. The separately exported canonical joint source retains its original Emperor-absent roster; no native card is swapped between these profiles.',
+ ],
+ };
+}
+export type RicheseKaramaBetrayalFixture = ReturnType;
+
+/** Original Emperor-absent canonical roster. The actual Emperor Nexus and
+ * Richese counter draw precede one native unbid-cache Karama retention. No
+ * ordinary recycle, held-card exchange or resource staging supplies this case. */
+export function createRicheseJointKaramaBetrayalFixture() {
+ const f = createNexusEmperorSellerFixture({ advanced: true, winnerFaction: 'richese', betrayal: 'before' });
+ const target = f.winner, holder = 'guild', draw = f.chronology.draw;
+ const drawAt = f.actions.findIndex(step => step.actor === draw.step.actor &&
+ JSON.stringify(step.action) === JSON.stringify(draw.step.action)); assert.ok(drawAt >= 0);
+ let game = structuredClone(draw.after);
+ const actions: RicheseKaramaBetrayalStep[] = structuredClone(f.actions.slice(0, drawAt + 1));
+ const act = (actor: string, action: Action) => { game = actRicheseKaramaBetrayal(game, actor, action, actions); };
+ const reach = (until: (state: Game) => boolean) => {
+ for (let count = 0; count < 600 && !until(game); count++) {
+ const d = game.decision;
+ if (!game.response && !game.phaseOpening && d?.kind === 'richeseDeclaration')
+ act(d.player, { type: 'decision', event: game.richeseBidding!.event, position: 'first' });
+ else {
+ const step = nextNexusEmperorSellerStep(game); act(step.actor, step.action);
+ }
+ }
+ assert.ok(until(game), 'The actual joint-source prefix must reach its next native control');
+ };
+ assert.equal(game.nexusCards!.cards!.hands[target], 'emperor');
+ assert.equal(game.nexusCards!.cards!.hands[holder], null);
+ const cards = game.nexusCards!.cards!, from = cards.deck.indexOf('richese'); assert.ok(from >= 0);
+ cards.deck.unshift(cards.deck.splice(from, 1)[0]);
+ const beforeCounterDraw = structuredClone(game), counterDrawStart = actions.length;
+ act(holder, { type: 'nexusCardChoice', turn: game.turn, card: null, choice: 'draw', ownRedraws: 0 });
+ const counterDraw = { before: beforeCounterDraw, steps: structuredClone(actions.slice(counterDrawStart)), after: structuredClone(game) };
+ reach(state => state.turn === 2 && state.phase === 2 && richeseKaramaBetrayalClean(state));
+ const afterClosingDraw = structuredClone(game);
+ let cost = richeseKaramaBetrayalPlayer(game, target).hand.find(card => card.effect === 'karama');
+ let costAcquisition: { before: Game; steps: RicheseKaramaBetrayalStep[]; after: Game } | null = null;
+ if (!cost) {
+ reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'richeseCache');
+ const chosen = game.richeseCache!.find(card => card.id === 'richese-karama'); assert.ok(chosen);
+ const before = structuredClone(game), start = actions.length;
+ act(target, { type: 'decision', event: game.richeseBidding!.event, card: chosen.id,
+ method: 'onceAround', direction: 'clockwise' });
+ for (let count = 0; game.decision?.kind !== 'richeseUnbid' && count < 20; count++) {
+ const lot = game.richeseAuction!; assert.ok(lot && lot.active && !game.response);
+ act(lot.active, { type: 'richeseBid', event: lot.event, amount: null });
+ }
+ assert.equal(game.decision?.kind, 'richeseUnbid');
+ act(target, { type: 'decision', event: game.richeseBidding!.event, keep: true });
+ reach(richeseKaramaBetrayalClean);
+ costAcquisition = { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
+ cost = richeseKaramaBetrayalPlayer(game, target).hand.find(card => card.id === chosen.id); assert.ok(cost);
+ }
+ assert.ok(cost);
+ assert.ok(!game.players.some(player => player.faction === 'emperor'));
+ assert.equal(richeseKaramaBetrayalPlayer(game, target).ally, null);
+ assert.equal(richeseKaramaBetrayalPlayer(game, holder).ally, null);
+ assert.equal(game.nexusCards!.cards!.hands[target], 'emperor');
+ assert.equal(game.nexusCards!.cards!.hands[holder], 'richese');
+ const offer = viewGame(game, target).richeseSpecialKarama; assert.ok(offer && !offer.blocked);
+ // The borrowed native first-turn driver already retains Stone Burner. Choose
+ // another actual, presently eligible cache face rather than assuming it is
+ // still in the cache after that real acquisition.
+ const chosen = offer.cards.find(card => card.id !== cost.id); assert.ok(chosen);
+ assert.ok(game.richeseCache!.some(card => card.id === chosen.id));
+ assert.equal(offer.emperorNexus.blocked, null); assert.ok(offer.emperorNexus.event);
+ const action: Action = { type: 'card', mode: 'special', card: cost.id, acquire: chosen.id,
+ emperorNexus: true, nexusEvent: offer.emperorNexus.event };
+ const source = structuredClone(game), originalInventory = f.originalCards;
+ const originalNexusInventory = richeseKaramaBetrayalNexusInventory(game);
+ assert.deepEqual(richeseKaramaBetrayalInventory(game), originalInventory);
+ return {
+ game, source, beforeCost: source, origin: 'special' as const, sourceKind: 'specialKarama' as const,
+ advanced: true, parent: 'quiet' as const, jointEmperor: true,
+ target, buyer: target, richese: target, owner: target, holder, emperor: null,
+ card: chosen.id, acquire: chosen.id, purchaseCard: structuredClone(chosen),
+ karama: cost.id, costCard: structuredClone(cost), action, activationAction: action,
+ nexusEvent: offer.emperorNexus.event, beforeCounterDraw, counterDraw, afterClosingDraw, costAcquisition,
+ originalResponse: structuredClone(game.response), originalDecision: structuredClone(game.decision),
+ originalInventory, originalCards: originalInventory, originalNexusInventory,
+ controlledRecycle: false, actions, steps: actions, sourceActionCount: actions.length,
+ parentFixture: f,
+ staging: [
+ 'Fork the exact original Richese/CHOAM/Harkonnen/Fremen/Guild canonical audit prefix after Richese actually draws Emperor Nexus at the second-turn worm. CHOAM/Fremen alliance is native; Richese and Guild remain unallied. Select only the still-unopened Richese Nexus draw for Guild, then finish actual closing/opening controls.',
+ 'If no original held Karama exists, declare the actual next cache Richese-Karama lot using Once Around; all original bidders pass and Richese really keeps that unbid cache card. Its original cache decreases and its real hand gains the physical cost. No dealt card, wallet, alliance, phase, Nexus or paused receipt is installed; no controlled recycled normal lot supplies this branch.',
+ `Stop before declaring the native joint special action with its actual private Emperor Nexus event. The held Karama, held Emperor singleton, three own spice proof, selected remaining original cache card ${chosen.id} and once-use remain unspent. The original first-turn driver already retained Stone Burner through its real unbid choice; the live private special-purchase view selects this different eligible cache card. The distinct Guild-held Richese singleton supplies the real pre-cost counter.`,
+ ],
+ };
+}
diff --git a/tests/nexus-emperor-special-purchase.test.ts b/tests/nexus-emperor-special-purchase.test.ts
index 79b1c389..8db639fb 100644
--- a/tests/nexus-emperor-special-purchase.test.ts
+++ b/tests/nexus-emperor-special-purchase.test.ts
@@ -88,16 +88,19 @@ void test('original Richese Nexus profiles compose, but unsupported full overlay
const skills = original();
skills.g.leaderSkills = {} as NonNullable;
assert.equal(quoteEmperorNexusSpecialPurchase(skills.g, 'r').blocked, null);
+ const independent = original();
+ independent.g.ecazTreachery = true;
+ assert.equal(quoteEmperorNexusSpecialPurchase(independent.g, 'r').blocked, null,
+ 'The selected E1/E2 independent-card profile already supports native purchases.');
for (const change of [
(g: Game) => { g.advancedPreview = true; },
- (g: Game) => { g.ecazTreachery = true; },
(g: Game) => { g.sandtrout = true; },
(g: Game) => { g.nexusCards = undefined; },
(g: Game) => { g.expansions = ['choam', 'ix', 'ecaz']; },
]) {
const { g, activation, acquire } = original();
change(g);
- assert.ok(quoteEmperorNexusSpecialPurchase(g, 'r').blocked);
+ assert.ok(quoteEmperorNexusSpecialPurchase(g, 'r').blocked, change.toString());
assert.throws(() => createEmperorNexusSpecialPurchase(g, 'r', activation.id, acquire.id),
EmperorNexusPurchaseError);
}
diff --git a/tests/nexus-richese-betrayal-karama-controls.test.tsx b/tests/nexus-richese-betrayal-karama-controls.test.tsx
new file mode 100644
index 00000000..33b78bdd
--- /dev/null
+++ b/tests/nexus-richese-betrayal-karama-controls.test.tsx
@@ -0,0 +1,156 @@
+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, type ReactNode } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { viewGame, type Action, type GameView } from '../game/engine';
+import { botNexusRicheseBetrayalActions } from '../game/bot-nexus-richese-betrayal';
+import { botActions } from '../game/bots';
+import { DIFFICULTIES } from '../game/bot-profiles';
+import { createRicheseBetrayalFixture } from './fixture-richese-betrayal';
+
+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 };
+ },
+});
+const { NexusRicheseBetrayal } = await import('../components/nexus-richese-betrayal');
+
+/** Projection overrides isolate real controls and policies. Native direct
+ * acquisition/cost settlement belongs to the separate engine producer cases. */
+function projected(source: 'karama' | 'specialKarama'): GameView {
+ const fixture = createRicheseBetrayalFixture('purchase');
+ const game = viewGame(fixture.game, fixture.holder);
+ assert.ok(game.richeseBetrayalReaction);
+ game.richeseBetrayalReaction.source = source;
+ game.richeseBetrayalReaction.price = source === 'karama' ? 0 : 3;
+ return game;
+}
+
+function markup(game: GameView, busy = false) {
+ return renderToStaticMarkup(createElement(NexusRicheseBetrayal, { game, busy, act() {} }));
+}
+
+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;
+}
+
+function choose(game: GameView, prefix: string, busy = false): Action[] {
+ const actions: Action[] = [];
+ click(NexusRicheseBetrayal({ game, busy, act(action) { actions.push(action); } }), prefix);
+ return actions;
+}
+
+void test('proactive ordinary Karama is an intended zero-spice acquisition with an explicit blind commitment', () => {
+ const game = projected('karama');
+ for (let render = 0; render < 2; render++) {
+ const html = markup(game);
+ assert.match(html, /before acquisition/);
+ assert.match(html, /intends to acquire the concealed ordinary auction lot using ordinary Karama/);
+ assert.match(html, /No spice would be paid/);
+ assert.doesNotMatch(html, /\bwon\b|for 0 spice|buying its own cache card|concealed Black Market lot/);
+ assert.match(html, /Provisional online rule: spend the Richese Nexus before learning whether this purchase can be stopped/);
+ assert.match(html, /Otherwise your Nexus remains spent and the original purchase continues/);
+ assert.match(html, /Provisional order: Betrayal resolves before Richese spends the costs of this intended acquisition/);
+ assert.match(html, /intended card is discarded without acquisition and Richese keeps its unplayed activation Karama/);
+ assert.match(html, /Spend Richese Nexus · attempt to stop this purchase/);
+ assert.match(html, /Passing lets the declared acquisition continue/);
+ assert.doesNotMatch(html, /refund|once-per-game special use remains available/);
+ }
+});
+
+void test('native special purchase stays private and explains unspent Karama, three spice and once-use', () => {
+ const game = projected('specialKarama');
+ const html = markup(game);
+ assert.match(html, /intends to acquire a privately selected Richese cache card using its native special Karama/);
+ assert.match(html, /listed price is 3 spice; the chosen face remains private/);
+ assert.match(html, /intended card is discarded without acquisition and Richese keeps its unplayed activation Karama/);
+ assert.match(html, /three spice are not paid and its once-per-game special use remains available/);
+ assert.match(html, /Use Richese Nexus · stop this private acquisition/);
+ assert.doesNotMatch(html, /\bwon\b|Provisional online rule: spend|Otherwise your Nexus remains spent|refund|Emperor Nexus|joint funding/);
+ game.richeseBetrayalReaction!.price = 0;
+ assert.equal(markup(game), html, 'The public listed-price explanation does not identify a private funding method.');
+});
+
+void test('both actual acquisition buttons send the unchanged single Use and Pass callbacks', () => {
+ for (const source of ['karama', 'specialKarama'] as const) {
+ const game = projected(source), event = game.richeseBetrayalReaction!.event;
+ assert.deepEqual(choose(game, source === 'karama' ? 'Spend Richese Nexus' : 'Use Richese Nexus'),
+ [{ type: 'richeseBetrayalUse', event }]);
+ assert.deepEqual(choose(game, 'Pass'), [{ type: 'richeseBetrayalPass', event }]);
+ }
+});
+
+void test('busy and private eligibility restrictions prevent spending but preserve Pass and persistent source guidance', () => {
+ for (const source of ['karama', 'specialKarama'] as const) {
+ const game = projected(source), prefix = source === 'karama' ? 'Spend Richese Nexus' : 'Use Richese Nexus';
+ assert.match(markup(game, true), /disabled=""[^>]*>(?:Spend|Use) Richese Nexus/);
+ assert.deepEqual(choose(game, prefix, true), []);
+ assert.deepEqual(choose(game, 'Pass', true), []);
+ game.richeseBetrayalReaction!.blocked = 'Your accepted promise reserves this Nexus.';
+ assert.match(markup(game), /accepted promise reserves this Nexus/);
+ assert.match(markup(game), /Provisional order/);
+ assert.deepEqual(choose(game, prefix), []);
+ assert.deepEqual(choose(game, 'Pass'), [{ type: 'richeseBetrayalPass', event: game.richeseBetrayalReaction!.event }]);
+ game.richeseBetrayalReaction!.blocked = null;
+ game.richeseBetrayalReaction!.canUse = false;
+ assert.deepEqual(choose(game, prefix), []);
+ game.richeseBetrayalReaction!.hasPassed = true;
+ assert.deepEqual(choose(game, 'Pass'), []);
+ game.richeseBetrayalReaction!.hasPassed = false;
+ game.richeseBetrayalReaction!.canPass = false;
+ assert.deepEqual(choose(game, prefix), []);
+ game.richeseBetrayalReaction!.canPass = true;
+ game.richeseBetrayalReaction!.canUse = true;
+ game.status = 'finished';
+ assert.deepEqual(choose(game, prefix), []);
+ }
+});
+
+void test('neither intended source reads an auction face, private cache, wallet or Nexus funding', () => {
+ for (const source of ['karama', 'specialKarama'] as const) {
+ const game = projected(source);
+ Object.defineProperty(game, 'auction', { get() { throw new Error('unentitled ordinary face'); } });
+ Object.defineProperty(game, 'richeseAuction', { get() { throw new Error('unrelated public cache lot'); } });
+ for (const player of game.players)
+ for (const field of ['hand', 'spice', 'richeseCache'])
+ Object.defineProperty(player, field, { get() { throw new Error(`unrelated ${field}`); } });
+ assert.match(markup(game), /before acquisition/);
+ assert.deepEqual(choose(game, source === 'karama' ? 'Spend Richese Nexus' : 'Use Richese Nexus'),
+ [{ type: 'richeseBetrayalUse', event: game.richeseBetrayalReaction!.event }]);
+ }
+});
+
+void test('all profiles pass blind Karama but may use the certain special veto only through private projected eligibility', () => {
+ for (const profile of DIFFICULTIES)
+ for (const source of ['karama', 'specialKarama'] as const) {
+ const game = projected(source), own = game.players.find(player => player.id === game.me)!;
+ own.bot = profile;
+ const event = game.richeseBetrayalReaction!.event;
+ const expected = [{ type: source === 'karama' ? 'richeseBetrayalPass' : 'richeseBetrayalUse', event }];
+ assert.deepEqual(botNexusRicheseBetrayalActions(game), expected);
+ assert.deepEqual(botActions(game), expected);
+ delete own.bot; own.autopilot = profile;
+ assert.deepEqual(botNexusRicheseBetrayalActions(game), expected);
+ game.richeseBetrayalReaction!.canUse = false;
+ assert.deepEqual(botNexusRicheseBetrayalActions(game), [{ type: 'richeseBetrayalPass', event }]);
+ game.richeseBetrayalReaction!.hasPassed = true;
+ assert.deepEqual(botNexusRicheseBetrayalActions(game), []);
+ }
+});
+
+aliases.deregister();
diff --git a/tests/nexus-richese-betrayal-karama.test.ts b/tests/nexus-richese-betrayal-karama.test.ts
new file mode 100644
index 00000000..fb9677c1
--- /dev/null
+++ b/tests/nexus-richese-betrayal-karama.test.ts
@@ -0,0 +1,194 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import type { Card } from '../game/cards';
+import type { NexusPlayer } from '../game/nexus-cards';
+import { richeseCards } from '../game/richese-cards';
+import {
+ createRicheseBetrayalReceipt, quoteRicheseKaramaBetrayalInvoice,
+ richeseBetrayalEvent, richeseBetrayalVetoesPurchase,
+ validateRicheseBetrayalCursor, validateRicheseBetrayalReceipt,
+ RicheseBetrayalError, type RicheseBetrayalInvoice,
+} from '../game/nexus-richese-betrayal';
+
+const players: NexusPlayer[] = [
+ { id: 'richese', faction: 'richese' },
+ { id: 'holder', faction: 'fremen' },
+ { id: 'emperor', faction: 'emperor' },
+];
+const costCard: Card = { id: 'cost-karama', name: 'Karama', kind: 'special', effect: 'karama' };
+const ordinary: Card = { id: 'normal-weapon', name: 'Maula Pistol', kind: 'projectile' };
+const spare: Card = { id: 'spare-source', name: 'Shield', kind: 'shield' };
+type KaramaInput = Parameters[0];
+
+function purchaseInput(source: KaramaInput['source'] = 'karama', card: Card = richeseCards()[0]): KaramaInput {
+ return {
+ players: players.map(player => ({ ...player })), owner: 'richese', source,
+ card: { ...card }, costCard: { ...costCard }, ownerCards: [{ ...costCard }],
+ sourceCards: [{ ...card }, { ...spare }],
+ physicalCards: [{ ...costCard }, { ...card }, { ...spare }], originalRecipient: 'emperor',
+ };
+}
+function usedReceipt(invoice: RicheseBetrayalInvoice) {
+ return createRicheseBetrayalReceipt({ turn: 2, players }, {
+ event: richeseBetrayalEvent(2, 1, 'round-2', 'lot-2'), turn: 2, sequence: 1,
+ round: 'round-2', lot: 'lot-2', invoice, required: ['holder'], passed: [],
+ stage: 'used', actor: 'holder', nexusDiscardIndex: 0,
+ });
+}
+function reject(input: KaramaInput) {
+ const before = JSON.stringify(input);
+ assert.throws(() => quoteRicheseKaramaBetrayalInvoice(input), RicheseBetrayalError);
+ assert.equal(JSON.stringify(input), before);
+}
+
+void test('pre-cost Karama invoices preserve the declared nominal price and clone the selected card', () => {
+ for (const [source, price] of [['karama', 0], ['specialKarama', 3]] as const) {
+ const input = purchaseInput(source);
+ const before = JSON.stringify(input);
+ const invoice = quoteRicheseKaramaBetrayalInvoice(input);
+ assert.deepEqual(invoice, {
+ kind: 'purchase', target: 'richese', buyer: 'richese', source,
+ price, ownPayment: price, allyPayment: 0, donor: null,
+ originalRecipient: 'emperor', card: input.card,
+ });
+ assert.equal(JSON.stringify(input), before);
+ assert.notEqual(invoice.card, input.card);
+ assert.notEqual(invoice.card, input.sourceCards[0]);
+ invoice.card.name = 'Changed output';
+ assert.equal(JSON.stringify(input), before);
+ assert.deepEqual(input.ownerCards, [costCard]);
+ }
+});
+
+void test('both nominal sources retain the original Emperor-or-bank recipient without creating a payment', () => {
+ for (const source of ['karama', 'specialKarama'] as const) {
+ const bank = purchaseInput(source);
+ bank.players = bank.players.filter(player => player.id !== 'emperor');
+ bank.originalRecipient = null;
+ assert.equal(quoteRicheseKaramaBetrayalInvoice(bank).originalRecipient, null);
+ const wrongRecipient = purchaseInput(source);
+ wrongRecipient.originalRecipient = 'holder';
+ reject(wrongRecipient);
+ const missingEmperor = purchaseInput(source);
+ missingEmperor.originalRecipient = null;
+ reject(missingEmperor);
+ }
+});
+
+void test('ordinary Karama admits either hidden family while special Karama binds a canonical cache card', () => {
+ for (const [card, hit] of [[richeseCards()[0], true], [ordinary, false]] as const) {
+ const input = purchaseInput('karama', card);
+ const before = JSON.stringify(input);
+ const invoice = quoteRicheseKaramaBetrayalInvoice(input);
+ assert.equal(invoice.source, 'karama');
+ assert.equal(invoice.price, 0);
+ assert.equal(richeseBetrayalVetoesPurchase(invoice), hit);
+ assert.equal(JSON.stringify(input), before);
+ }
+ assert.equal(richeseBetrayalVetoesPurchase(quoteRicheseKaramaBetrayalInvoice(purchaseInput('specialKarama'))), true);
+ reject(purchaseInput('specialKarama', ordinary));
+ reject(purchaseInput('specialKarama', { ...richeseCards()[0], name: 'Counterfeit Richese face' }));
+});
+
+void test('the cost stays held and distinct from one exact selected source card', () => {
+ const sameCard = purchaseInput();
+ sameCard.card = { ...sameCard.costCard };
+ reject(sameCard);
+ const noHeldCost = purchaseInput();
+ noHeldCost.ownerCards = [];
+ reject(noHeldCost);
+ const duplicateHeldCost = purchaseInput();
+ duplicateHeldCost.ownerCards = [costCard, { ...costCard }];
+ reject(duplicateHeldCost);
+ const substitutedCost = purchaseInput();
+ substitutedCost.ownerCards = [{ ...costCard, name: 'Different held face' }];
+ reject(substitutedCost);
+ const noSource = purchaseInput();
+ noSource.sourceCards = [spare];
+ reject(noSource);
+ const duplicateSource = purchaseInput();
+ duplicateSource.sourceCards = [duplicateSource.card, { ...duplicateSource.card }];
+ reject(duplicateSource);
+ const substitutedSource = purchaseInput();
+ substitutedSource.sourceCards = [{ ...substitutedSource.card, name: 'Different source face' }];
+ reject(substitutedSource);
+ for (const id of [costCard.id, richeseCards()[0].id]) {
+ const absent = purchaseInput();
+ absent.physicalCards = absent.physicalCards.filter(card => card.id !== id);
+ reject(absent);
+ const duplicated = purchaseInput();
+ duplicated.physicalCards = [...duplicated.physicalCards, { ...duplicated.physicalCards.find(card => card.id === id)! }];
+ reject(duplicated);
+ }
+});
+
+void test('Karama purchase quotes reject foreign owners, wrong producer names and malformed physical faces', () => {
+ for (const owner of ['', 'holder', 'unknown']) {
+ const input = purchaseInput();
+ input.owner = owner;
+ reject(input);
+ }
+ const wrongSource = purchaseInput();
+ wrongSource.source = 'normal' as KaramaInput['source'];
+ reject(wrongSource);
+ const malformedCost = purchaseInput();
+ malformedCost.costCard = { ...costCard, kind: 'invalid' } as unknown as Card;
+ reject(malformedCost);
+ const malformedSource = purchaseInput();
+ malformedSource.card = { ...ordinary, id: '' };
+ reject(malformedSource);
+});
+
+void test('receipt validation keeps zero and three prices exclusive to their original Karama source', () => {
+ for (const source of ['karama', 'specialKarama'] as const) {
+ const invoice = quoteRicheseKaramaBetrayalInvoice(purchaseInput(source));
+ const invalid: RicheseBetrayalInvoice[] = [
+ ...[-1, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, source === 'karama' ? 3 : 0].map(price => ({
+ ...invoice, price, ownPayment: price,
+ })),
+ { ...invoice, kind: 'sale', buyer: 'holder', originalRecipient: 'richese' },
+ { ...invoice, allyPayment: 1, ownPayment: invoice.price - 1, donor: 'emperor' },
+ ];
+ for (const changed of invalid) assert.throws(() => usedReceipt(changed), RicheseBetrayalError);
+ }
+ const legacyZero = { ...quoteRicheseKaramaBetrayalInvoice(purchaseInput()), source: 'normal' as const };
+ assert.throws(() => usedReceipt(legacyZero), RicheseBetrayalError);
+});
+
+void test('a blind ordinary Karama miss remains valid used history with one committed Nexus cost', () => {
+ for (const [card, hit] of [[richeseCards()[0], true], [ordinary, false]] as const) {
+ const receipt = usedReceipt(quoteRicheseKaramaBetrayalInvoice(purchaseInput('karama', card)));
+ assert.equal(receipt.stage, 'used');
+ assert.equal(receipt.nexusDiscardIndex, 0);
+ assert.equal(richeseBetrayalVetoesPurchase(receipt.invoice), hit);
+ validateRicheseBetrayalReceipt({ turn: 3, players }, JSON.parse(JSON.stringify(receipt)));
+ validateRicheseBetrayalCursor({ turn: 3, players }, {
+ sequence: 1, current: null, completed: [receipt],
+ }, null);
+ assert.throws(() => validateRicheseBetrayalReceipt({ turn: 3, players }, {
+ ...receipt, nexusDiscardIndex: null,
+ }), RicheseBetrayalError);
+ }
+});
+
+void test('all older source receipt bytes remain unchanged by the new Karama source names', () => {
+ for (const source of ['cache', 'blackMarket', 'normal'] as const) {
+ const invoice: RicheseBetrayalInvoice = source === 'blackMarket' ? {
+ kind: 'sale', target: 'richese', buyer: 'emperor', source,
+ price: 5, ownPayment: 5, allyPayment: 0, donor: null,
+ originalRecipient: 'richese', card: { ...ordinary },
+ } : {
+ kind: 'purchase', target: 'richese', buyer: 'richese', source,
+ price: 5, ownPayment: 5, allyPayment: 0, donor: null,
+ originalRecipient: 'emperor', card: richeseCards()[0],
+ };
+ const receipt = usedReceipt(invoice);
+ const event = JSON.stringify(['nexusRicheseBetrayal', 2, 1, 'round-2', 'lot-2']);
+ assert.equal(receipt.signature, JSON.stringify([
+ 'richeseBetrayalReceipt', 1, event, 2, 1, 'round-2', 'lot-2',
+ invoice.kind, 'richese', invoice.buyer, source, 5, 5, 0, null, invoice.originalRecipient,
+ [invoice.card.id, invoice.card.name, invoice.card.kind, invoice.card.effect ?? null],
+ ['holder'], [], 'used', 'holder', 0,
+ ]));
+ }
+});
diff --git a/tests/normal-auction-retirement.test.ts b/tests/normal-auction-retirement.test.ts
new file mode 100644
index 00000000..95e3abbe
--- /dev/null
+++ b/tests/normal-auction-retirement.test.ts
@@ -0,0 +1,184 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { baseDeck } from '../game/cards';
+import { quoteAuctionContinuation, type AuctionContinuationInput } from '../game/auction-continuation-quote';
+import {
+ quoteNormalAuctionRetirement, NormalAuctionRetirementError,
+ type NormalAuctionRetirementInput,
+} from '../game/normal-auction-retirement';
+
+function fixture(): NormalAuctionRetirementInput {
+ const cards = baseDeck().slice(0, 3);
+ const held = baseDeck()[3];
+ return {
+ turn: 2, advanced: false, order: ['r', 'a', 'i', 'g'],
+ players: [
+ { id: 'r', faction: 'richese', spice: 5, hand: [], handLimit: 4 },
+ { id: 'a', faction: 'atreides', spice: 5, hand: [], handLimit: 4 },
+ { id: 'i', faction: 'ixians', spice: 5, hand: [held], handLimit: 4 },
+ { id: 'g', faction: 'guild', spice: 5, hand: [], handLimit: 4 },
+ ],
+ physicalCards: [...cards, held],
+ auction: { cards, index: 0, bid: 0, bidder: null, active: 'r', passed: [], opener: 0 },
+ };
+}
+function round(input: NormalAuctionRetirementInput) {
+ input.richeseBidding = {
+ owner: 'r', event: 'round-2', turn: 2, stage: 'normal', position: 'last',
+ normalCount: 3, blackMarketSold: false, cacheCanceled: false,
+ };
+}
+function paid(input: NormalAuctionRetirementInput): AuctionContinuationInput {
+ return {
+ ...input, status: 'playing', phase: 3,
+ auction: { ...input.auction!, bid: 4, bidder: 'r' },
+ };
+}
+function reject(input: NormalAuctionRetirementInput) {
+ const before = JSON.stringify(input);
+ assert.throws(() => quoteNormalAuctionRetirement(input), NormalAuctionRetirementError);
+ assert.equal(JSON.stringify(input), before);
+}
+
+void test('unbought retirement advances the original pool without a bidder, sale or payment', () => {
+ const input = fixture(), before = structuredClone(input);
+ const next = quoteNormalAuctionRetirement(input);
+ assert.equal(next.kind, 'normalLot');
+ if (next.kind !== 'normalLot') throw new Error('The next physical lot must remain.');
+ assert.equal(next.auction.index, 1);
+ assert.equal(next.auction.bid, 0);
+ assert.equal(next.auction.bidder, null);
+ assert.equal(next.auction.allyPayment, 0);
+ assert.equal(next.active, 'a');
+ assert.deepEqual(next.offer, { kind: 'atreidesAuction', owner: 'a', passed: [] });
+ assert.equal(Object.hasOwn(next, 'sale'), false);
+ assert.deepEqual(input, before);
+ next.auction.cards[1].name = 'Changed output';
+ assert.deepEqual(input, before);
+});
+
+void test('retirement rotates the physical opener rather than another seat currently leading the bid', () => {
+ const input = fixture();
+ input.auction = { ...input.auction!, bid: 5, bidder: 'g', active: 'i', passed: ['r', 'a'] };
+ const next = quoteNormalAuctionRetirement(input);
+ assert.equal(next.kind, 'normalLot');
+ if (next.kind !== 'normalLot') throw new Error('The next physical lot must remain.');
+ assert.equal(next.active, 'a');
+ assert.equal(next.auction.opener, 1);
+ assert.equal(next.auction.bidder, null);
+});
+
+void test('next-lot retirement skips full hands, removes Sapho and clears the prior inspection', () => {
+ const input = fixture();
+ input.players = input.players.map(player => player.id === 'a'
+ ? { ...player, hand: [{ id: 'a-full' }], handLimit: 1 } : player);
+ input.auction = { ...input.auction!, saphoOrder: ['g', 'i', 'a', 'r'], peekKnown: true,
+ occupiedEvent: 'original-occupied-event' };
+ const before = structuredClone(input), next = quoteNormalAuctionRetirement(input);
+ assert.equal(next.kind, 'normalLot');
+ if (next.kind !== 'normalLot') throw new Error('The next physical lot must remain.');
+ assert.equal(next.active, 'i');
+ assert.equal(next.auction.opener, 2);
+ assert.deepEqual(next.auction.passed, []);
+ assert.equal(next.auction.peekKnown, false);
+ assert.equal(Object.hasOwn(next.auction, 'saphoOrder'), false);
+ assert.equal(next.auction.occupiedEvent, 'original-occupied-event');
+ assert.deepEqual(input, before);
+});
+
+void test('exhaustion keeps the 7 October last-cache stock and cancellation rule', () => {
+ for (const [stock, canceled, after] of [[1, false, 'richeseCache'], [0, false, 'phase'], [1, true, 'phase']] as const) {
+ const input = fixture();
+ round(input);
+ input.auction!.index = 2;
+ input.richeseCacheCount = stock;
+ input.richeseBidding!.cacheCanceled = canceled;
+ assert.deepEqual(quoteNormalAuctionRetirement(input), {
+ kind: 'normalEnd', returned: [], after, completeRound: after === 'phase',
+ });
+ }
+ const ordinary = fixture();
+ ordinary.auction!.index = 2;
+ assert.deepEqual(quoteNormalAuctionRetirement(ordinary), {
+ kind: 'normalEnd', returned: [], after: 'phase', completeRound: false,
+ });
+});
+
+void test('full hands return only the untouched remainder as independent physical snapshots', () => {
+ const input = fixture();
+ input.players = input.players.map(player => ({ ...player, hand: [{ id: `${player.id}-full` }], handLimit: 1 }));
+ const before = structuredClone(input), next = quoteNormalAuctionRetirement(input);
+ assert.equal(next.kind, 'normalEnd');
+ if (next.kind !== 'normalEnd') throw new Error('No seat can buy the remaining cards.');
+ assert.deepEqual(next.returned, input.auction!.cards.slice(1));
+ next.returned[0].name = 'Changed output';
+ assert.deepEqual(input, before);
+});
+
+void test('next inspection offers retain Advanced Ix priority and ordinary Atreides fallback', () => {
+ const input = fixture();
+ input.advanced = true;
+ const ix = quoteNormalAuctionRetirement(input);
+ assert.equal(ix.kind, 'normalLot');
+ if (ix.kind !== 'normalLot') throw new Error('The next physical lot must remain.');
+ assert.deepEqual(ix.offer, { kind: 'ixTechnology', player: 'i' });
+ input.ixTechnologyTurn = input.turn;
+ const atreides = quoteNormalAuctionRetirement(input);
+ assert.equal(atreides.kind, 'normalLot');
+ if (atreides.kind !== 'normalLot') throw new Error('The next physical lot must remain.');
+ assert.deepEqual(atreides.offer, { kind: 'atreidesAuction', owner: 'a', passed: [] });
+ input.players = input.players.map(player => player.id === 'a' ? { ...player, faction: 'beneGesserit' } : player);
+ const neither = quoteNormalAuctionRetirement(input);
+ assert.equal(neither.kind, 'normalLot');
+ if (neither.kind !== 'normalLot') throw new Error('The next physical lot must remain.');
+ assert.equal(neither.offer, null);
+});
+
+void test('the existing paid continuation delegates unchanged and preserves declared-cost hand projection', () => {
+ const input = paid(fixture());
+ assert.deepEqual(quoteAuctionContinuation(input, { kind: 'next' }).next, quoteNormalAuctionRetirement(input));
+ const cost = { id: 'r-last-slot' };
+ input.players = input.players.map(player => ({ ...player, hand: [player.id === 'r' ? cost : { id: `${player.id}-full` }], handLimit: 1 }));
+ const before = structuredClone(input);
+ const next = quoteAuctionContinuation(input, { kind: 'next' }, { player: 'r', card: cost.id }).next;
+ assert.equal(next.kind, 'normalLot');
+ if (next.kind !== 'normalLot') throw new Error('Spending the real card opens the original slot.');
+ assert.equal(next.active, 'r');
+ assert.deepEqual(input, before);
+ const ixInput = paid(fixture());
+ ixInput.advanced = true;
+ const ixCard = ixInput.players.find(player => player.id === 'i')!.hand[0].id;
+ const afterIxCost = quoteAuctionContinuation(ixInput, { kind: 'next' }, { player: 'i', card: ixCard }).next;
+ assert.equal(afterIxCost.kind, 'normalLot');
+ if (afterIxCost.kind !== 'normalLot') throw new Error('The next physical lot must remain.');
+ assert.deepEqual(afterIxCost.offer, { kind: 'atreidesAuction', owner: 'a', passed: [] });
+});
+
+void test('retirement rejects invalid source indices, order, hand limits and remaining physical custody', () => {
+ for (const index of [-1, 3, 0.5, NaN, Infinity]) {
+ const input = fixture();
+ input.auction!.index = index;
+ reject(input);
+ }
+ const badOpener = fixture();
+ badOpener.auction!.opener = 4;
+ reject(badOpener);
+ const nullBidderPrice = fixture();
+ nullBidderPrice.auction!.bid = 1;
+ reject(nullBidderPrice);
+ const duplicateOrder = fixture();
+ duplicateOrder.order = ['r', 'r'];
+ reject(duplicateOrder);
+ const noHandLimit = fixture();
+ noHandLimit.players = noHandLimit.players.map(player => player.id === 'r' ? { ...player, handLimit: 0 } : player);
+ reject(noHandLimit);
+ const noPhysical = fixture();
+ noPhysical.physicalCards = noPhysical.physicalCards.filter(card => card.id !== noPhysical.auction!.cards[1].id);
+ reject(noPhysical);
+ const duplicatePhysical = fixture();
+ duplicatePhysical.physicalCards = [...duplicatePhysical.physicalCards, duplicatePhysical.auction!.cards[1]];
+ reject(duplicatePhysical);
+ const duplicateCurrent = fixture();
+ duplicateCurrent.auction!.cards[1] = duplicateCurrent.auction!.cards[0];
+ reject(duplicateCurrent);
+});
diff --git a/tests/richese-karama-betrayal-engine.test.ts b/tests/richese-karama-betrayal-engine.test.ts
new file mode 100644
index 00000000..da8b93ce
--- /dev/null
+++ b/tests/richese-karama-betrayal-engine.test.ts
@@ -0,0 +1,208 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { applyAction, viewGame, type Action, type Game } from '../game/engine';
+import { botActions } from '../game/bots';
+import { DIFFICULTIES } from '../game/bot-profiles';
+import {
+ createRicheseKaramaBetrayalFixture as fixture,
+ createRicheseJointKaramaBetrayalFixture as jointFixture,
+ actRicheseKaramaBetrayal as act,
+ commitRicheseKaramaBetrayal as commit,
+ passRicheseKaramaBetrayal as pass,
+ finishRicheseKaramaBetrayal as finish,
+ richeseKaramaBetrayalPlayer as player,
+ richeseKaramaBetrayalInventory as inventory,
+ richeseKaramaBetrayalNexusInventory as nexusInventory,
+ type RicheseKaramaBetrayalFixture,
+} from './fixture-richese-karama-betrayal';
+
+const wallets = (g: Game) => Object.fromEntries(g.players.map(p => [p.id, p.spice]));
+function reject(g: Game, actor: string, action: Action) {
+ const before = structuredClone(g); assert.throws(() => applyAction(g, actor, action)); assert.deepEqual(g, before);
+}
+function open(f: Pick) {
+ const g = commit(f.game, f).game, receipt = g.pendingRicheseBetrayal?.receipt; assert.ok(receipt);
+ assert.equal(receipt.invoice.source, f.sourceKind); assert.equal(receipt.invoice.card.id, f.card);
+ assert.equal(receipt.invoice.buyer, f.target); assert.equal(receipt.invoice.price, f.origin === 'ordinary' ? 0 : 3);
+ assert.deepEqual(wallets(g), wallets(f.game));
+ assert.ok(player(g, f.target).hand.some(card => card.id === f.karama));
+ assert.equal(player(g, f.target).hand.some(card => card.id === f.card), false);
+ assert.equal(g.discard.some(card => card.id === f.karama), false);
+ assert.equal(!!player(g, f.target).specialKaramaUsed, false);
+ assert.equal(g.nexusCards!.cards!.hands[f.holder], 'richese');
+ assert.equal(viewGame(g, f.holder).richeseBetrayalReaction!.canUse, true);
+ assert.deepEqual(inventory(g), f.originalInventory); assert.deepEqual(nexusInventory(g), f.originalNexusInventory);
+ return { game: g, event: receipt.event, use: { type: 'richeseBetrayalUse', event: receipt.event } as Action };
+}
+function conserved(g: Game, f: Pick) {
+ assert.ok(!g.pendingRicheseBetrayal); assert.equal(g.richeseBetrayal!.current, null);
+ assert.deepEqual(inventory(g), f.originalInventory); assert.deepEqual(nexusInventory(g), f.originalNexusInventory);
+}
+
+for (const advanced of [false, true]) {
+ const mode = advanced ? 'Advanced' : 'Basic';
+ void test(`${mode} ordinary Karama Richese-family veto happens before its original physical cost`, () => {
+ const f = fixture({ advanced }), pending = open(f), before = wallets(f.game), index = f.game.auction!.index;
+ assert.equal(f.controlledRecycle, true); assert.notEqual(f.karama, f.card);
+ assert.equal(f.sourceActionCount, f.actions.length);
+ const done = finish(act(pending.game, f.holder, pending.use));
+ assert.deepEqual(wallets(done), before);
+ assert.ok(player(done, f.target).hand.some(card => card.id === f.karama));
+ assert.equal(done.discard.some(card => card.id === f.karama), false);
+ assert.equal(done.discard.filter(card => card.id === f.card).length, 1);
+ assert.equal(player(done, f.target).hand.some(card => card.id === f.card), false);
+ assert.equal(done.nexusCards!.cards!.hands[f.holder], null);
+ assert.equal(done.auction!.index, index + 1); assert.ok(!done.currentAuctionSale);
+ assert.equal(done.richeseBetrayal!.completed.at(-1)!.stage, 'used'); conserved(done, f);
+ });
+
+ void test(`${mode} ordinary Karama MISS spends its Nexus then the actual Karama and acquires free once`, () => {
+ const f = fixture({ advanced, richeseCard: false }), pending = open(f), before = wallets(f.game);
+ const done = finish(act(pending.game, f.holder, pending.use));
+ assert.deepEqual(wallets(done), before);
+ assert.equal(done.nexusCards!.cards!.hands[f.holder], null);
+ assert.equal(done.discard.filter(card => card.id === f.karama).length, 1);
+ assert.equal(player(done, f.target).hand.filter(card => card.id === f.card).length, 1);
+ assert.equal(done.discard.some(card => card.id === f.card), false);
+ assert.equal(!!player(done, f.target).specialKaramaUsed, false);
+ assert.equal(done.richeseBetrayal!.completed.at(-1)!.stage, 'used'); conserved(done, f);
+ });
+
+ void test(`${mode} ordinary Karama all-pass retains the Nexus and completes its native free acquisition`, () => {
+ const f = fixture({ advanced }), pending = open(f), before = wallets(f.game);
+ const done = finish(pass(pending.game));
+ assert.deepEqual(wallets(done), before);
+ assert.equal(done.nexusCards!.cards!.hands[f.holder], 'richese');
+ assert.equal(done.discard.filter(card => card.id === f.karama).length, 1);
+ assert.equal(player(done, f.target).hand.filter(card => card.id === f.card).length, 1);
+ assert.equal(done.richeseBetrayal!.completed.at(-1)!.stage, 'passed'); conserved(done, f);
+ });
+}
+
+void test('an actual competing ordinary bid is retained unpaid through the pre-cost Karama choice', () => {
+ for (const use of [false, true]) {
+ const f = fixture({ advanced: true, otherBidder: true }), pending = open(f), before = wallets(f.game);
+ assert.equal(f.game.auction!.bidder, f.holder); assert.equal(f.game.auction!.bid, 1);
+ const done = finish(use ? act(pending.game, f.holder, pending.use) : pass(pending.game));
+ assert.deepEqual(wallets(done), before);
+ assert.equal(player(done, f.target).hand.some(card => card.id === f.karama), use);
+ assert.equal(player(done, f.target).hand.some(card => card.id === f.card), !use);
+ assert.equal(player(done, f.holder).hand.some(card => card.id === f.card), false); conserved(done, f);
+ }
+});
+
+void test('native private special veto discards only the selected cache face before Karama, spice and once-use costs', () => {
+ const f = fixture({ origin: 'special' }), pending = open(f), before = wallets(f.game), cache = f.game.richeseCache!;
+ assert.equal(f.game.phase, 2); assert.equal(f.controlledRecycle, false);
+ const done = finish(act(pending.game, f.holder, pending.use));
+ assert.deepEqual(wallets(done), before);
+ assert.ok(player(done, f.target).hand.some(card => card.id === f.karama));
+ assert.equal(!!player(done, f.target).specialKaramaUsed, false);
+ assert.equal(done.discard.filter(card => card.id === f.card).length, 1);
+ assert.deepEqual(done.richeseCache!.map(card => card.id), cache.filter(card => card.id !== f.card).map(card => card.id));
+ assert.equal(done.discard.some(card => card.id === f.karama), false);
+ assert.equal(done.nexusCards!.cards!.hands[f.holder], null);
+ assert.ok(!done.pendingRichesePurchaseIncome); assert.ok(!done.currentAuctionSale); conserved(done, f);
+});
+
+void test('native private special all-pass pays three, disposes the original Karama and settles native Emperor income once', () => {
+ const f = fixture({ origin: 'special' }), pending = open(f), before = wallets(f.game);
+ const committed = pass(pending.game);
+ assert.equal(player(committed, f.target).spice, before[f.target] - 3);
+ assert.equal(player(committed, f.target).specialKaramaUsed, true);
+ // Spending the only opposing Karama leaves no income cancellation actor;
+ // native automatic response settlement has already credited the Emperor.
+ assert.ok(!committed.response); assert.ok(!committed.pendingRichesePurchaseIncome);
+ assert.equal(player(committed, f.emperor).spice, before[f.emperor] + 3);
+ const done = finish(committed);
+ assert.equal(player(done, f.target).spice, before[f.target] - 3);
+ assert.equal(player(done, f.emperor).spice, before[f.emperor] + 3);
+ assert.equal(done.discard.filter(card => card.id === f.karama).length, 1);
+ assert.equal(player(done, f.target).hand.filter(card => card.id === f.card).length, 1);
+ assert.equal(done.richeseCache!.length, f.game.richeseCache!.length - 1);
+ assert.equal(done.nexusCards!.cards!.hands[f.holder], 'richese');
+ assert.ok(!done.pendingRichesePurchaseIncome); conserved(done, f);
+});
+
+for (const use of [false, true]) void test(
+ `native private special ${use ? 'veto' : 'allowance'} preserves the already pending ordinary Emperor income parent`, () => {
+ const f = fixture({ origin: 'special', parent: 'emperorIncome' }), pending = open(f), before = wallets(f.game);
+ assert.equal(f.game.response?.kind, 'emperorIncome');
+ assert.equal(f.game.currentAuctionSale?.amount ?? f.game.auction?.bid, 3);
+ assert.equal(player(f.game, f.target).spice, 4);
+ const committed = use ? act(pending.game, f.holder, pending.use) : pass(pending.game);
+ if (use) {
+ assert.deepEqual(committed.response, f.originalResponse);
+ assert.equal(player(committed, f.target).spice, 4);
+ assert.ok(player(committed, f.target).hand.some(card => card.id === f.karama));
+ assert.equal(!!player(committed, f.target).specialKaramaUsed, false);
+ } else {
+ // The preceding paid auction really acquired another Karama, so the
+ // child income still has a cancellation actor and saves its parent.
+ assert.equal(committed.response?.kind, 'richesePurchaseIncome');
+ assert.equal(committed.response?.amount, 3);
+ assert.deepEqual(committed.pendingRichesePurchaseIncome!.resume.response, f.originalResponse);
+ assert.equal(player(committed, f.emperor).spice, before[f.emperor]);
+ assert.equal(player(committed, f.target).spice, 1);
+ }
+ const done = finish(committed);
+ assert.equal(player(done, f.emperor).spice, before[f.emperor] + (use ? 3 : 6));
+ assert.equal(player(done, f.target).spice, use ? 4 : 1);
+ assert.equal(player(done, f.target).hand.some(card => card.id === f.card), !use);
+ assert.equal(done.discard.filter(card => card.id === f.karama).length, Number(!use));
+ assert.ok(!done.pendingRichesePurchaseIncome); conserved(done, f);
+ },
+);
+
+void test('all four existing counter policies act legally on both ordinary faces and the private special source', () => {
+ for (const options of [{ richeseCard: true }, { richeseCard: false }, { origin: 'special' as const }]) {
+ const f = fixture(options), pending = open(f);
+ for (const difficulty of DIFFICULTIES) {
+ const own = viewGame(pending.game, f.holder); own.players.find(p => p.id === f.holder)!.bot = difficulty;
+ const action = botActions(own)[0]; assert.ok(action, difficulty);
+ assert.ok(action.type === 'richeseBetrayalPass' || action.type === 'richeseBetrayalUse');
+ assert.equal(action.event, pending.event);
+ const done = finish(act(pending.game, f.holder, action)); conserved(done, f);
+ }
+ }
+});
+
+void test('pre-cost Karama counters reject underlying resubmission and stale owner replies without changing physical costs', () => {
+ for (const origin of ['ordinary', 'special'] as const) {
+ const f = fixture({ origin }), pending = open(f);
+ reject(pending.game, f.target, f.action);
+ reject(pending.game, f.target, pending.use);
+ reject(pending.game, f.holder, { ...pending.use, event: `${pending.event}:stale` });
+ const done = finish(act(pending.game, f.holder, pending.use));
+ reject(done, f.holder, pending.use);
+ assert.ok(player(done, f.target).hand.some(card => card.id === f.karama));
+ conserved(done, f);
+ }
+});
+
+for (const use of [false, true]) void test(
+ `native joint Emperor-Nexus private special ${use ? 'veto preserves both original costs' : 'allowance spends both costs and retains proved spice'}`, () => {
+ const f = jointFixture(), before = wallets(f.game), pending = open(f);
+ assert.equal(f.controlledRecycle, false); assert.equal(f.sourceActionCount, f.actions.length);
+ assert.equal(f.game.players.some(player => player.faction === 'emperor'), false);
+ assert.equal(f.game.nexusCards!.cards!.hands[f.target], 'emperor');
+ assert.equal(f.game.nexusCards!.cards!.hands[f.holder], 'richese');
+ if (f.costAcquisition) {
+ assert.ok(f.costAcquisition.steps.some(step => step.actor === f.target && step.action.card === f.karama));
+ assert.ok(f.costAcquisition.steps.some(step => step.actor === f.target && step.action.keep === true));
+ }
+ const historyBefore = f.game.nexusEmperorPurchaseHistory?.length ?? 0;
+ const done = finish(use ? act(pending.game, f.holder, pending.use) : pass(pending.game));
+ assert.deepEqual(wallets(done), before);
+ assert.equal(player(done, f.target).hand.some(card => card.id === f.karama), use);
+ assert.equal(done.discard.filter(card => card.id === f.karama).length, Number(!use));
+ assert.equal(done.nexusCards!.cards!.hands[f.target], use ? 'emperor' : null);
+ assert.equal(done.nexusCards!.cards!.hands[f.holder], use ? null : 'richese');
+ assert.equal(player(done, f.target).hand.filter(card => card.id === f.card).length, Number(!use));
+ assert.equal(done.discard.filter(card => card.id === f.card).length, Number(use));
+ assert.equal(!!player(done, f.target).specialKaramaUsed, !use);
+ assert.equal(done.nexusEmperorPurchaseHistory?.length ?? 0, historyBefore + Number(!use));
+ assert.ok(!done.pendingRichesePurchaseIncome); conserved(done, f);
+ },
+);