Connect Emperor Nexus retention to original Richese seller lots
This commit is contained in:
1 parent
22948ff6e4
commit
d3e1c2f242
21 files changed
+1314
-67
No files matched your search
@@ -237,6 +237,7 @@ import { MoritaniAssassinate, MoritaniAssassinateHistory } from './moritani-assa
|
||||
import { MoritaniExtortion, MoritaniExtortionNotice } from './moritani-extortion';
|
||||
import { AdvancedAllySeparation } from './advanced-ally-separation';
|
||||
import { NexusEmperorPayment } from './nexus-emperor-payment';
|
||||
import { NexusEmperorSellerPurchase } from './nexus-emperor-seller-purchase';
|
||||
export function GameTable({
|
||||
game: g,
|
||||
send,
|
||||
@@ -3514,6 +3515,8 @@ export function GameTable({
|
||||
</>
|
||||
) : g.decision.kind === 'nexusEmperorPayment' ? (
|
||||
<NexusEmperorPayment game={g} send={act} busy={transportBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot} />
|
||||
) : g.decision.kind === 'nexusEmperorSellerPurchase' ? (
|
||||
<NexusEmperorSellerPurchase game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot} />
|
||||
) : g.decision.kind === 'auctionPayment' ? (
|
||||
<>
|
||||
<p className="muted">
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
export function NexusEmperorSellerPurchase({ game, act, busy }: {
|
||||
game: GameView;
|
||||
act: (action: Action) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const decision = game.decision;
|
||||
if (decision?.kind !== 'nexusEmperorSellerPurchase' || decision.player !== game.me) return null;
|
||||
const own = game.players.find(player => player.id === game.me);
|
||||
const offer = game.nexusEmperorSellerPurchase;
|
||||
const seller = offer && game.players.find(player => player.id === offer.seller);
|
||||
const disabled = busy || game.status !== 'playing' || game.phase !== 3 ||
|
||||
game.automaticContinuationPending || !!game.phaseOpening || !!game.response ||
|
||||
!!game.truthtrance || !!game.nexusTraitors?.pending ||
|
||||
!!game.roomControl?.paused || !!game.roomControl?.closed || !!own?.autopilot;
|
||||
const entitled = !!offer && offer.event === decision.event && offer.canUse && !offer.blocked;
|
||||
|
||||
return (
|
||||
<section aria-label="Richese purchase confirmation" className="notice min-w-0 space-y-3 break-words">
|
||||
<h3>Confirm your winning purchase</h3>
|
||||
<p>Pay the original winning price to complete this Treachery Card purchase.
|
||||
This confirmation does not change the winning lot or bid.</p>
|
||||
{offer && <>
|
||||
<h4>Emperor Nexus · Secret Ally</h4>
|
||||
<p>The original {offer.source === 'cache' ? 'Richese cache' : 'Black Market'} price is
|
||||
{' '}{offer.price} spice. Show that you personally hold the full amount to keep your spice
|
||||
while acquiring the original card.</p>
|
||||
<p className="fine">Provisional seller policy, not a publisher ruling:
|
||||
{' '}{seller?.name ?? offer.seller} remains unpaid. No bank reimbursement or substitute
|
||||
payer is created. {offer.policy} Using this option spends your Emperor Nexus Card.</p>
|
||||
{offer.blocked && <output className="fine block">{offer.blocked}</output>}
|
||||
</>}
|
||||
<Button className="game-action min-h-11 whitespace-normal" disabled={disabled}
|
||||
onClick={() => { if (!disabled) act({ type: 'nexusEmperorSellerPurchase', event: decision.event, use: false }); }}>
|
||||
Pay original winning price
|
||||
</Button>
|
||||
{offer && entitled && <Button className="game-action min-h-11 whitespace-normal" disabled={disabled}
|
||||
onClick={() => { if (!disabled && entitled) act({ type: 'nexusEmperorSellerPurchase', event: decision.event, use: true }); }}>
|
||||
Use Emperor Nexus · keep {offer.price} spice; seller unpaid
|
||||
</Button>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -29,6 +29,21 @@ optional skill bands, combinations and public expansion starts remain open.
|
||||
Rules implementation continues; comprehensive assurance and administration
|
||||
remain deferred until all rules are implemented.
|
||||
|
||||
**Emperor Secret Ally original Richese purchases — 14 October.** Basic/Advanced
|
||||
cache, legal self-cache and Advanced Black Market now connect uniform buyer
|
||||
Pay/eligible Use after original Richese Betrayal. Recommended provisional
|
||||
seller-unpaid policy retains the personally affordable full price without bank
|
||||
reimbursement. Original self-discard prevents acquisition/Nexus spend; diverted
|
||||
bank provenance does not create payment on Use. Signed source receipts preserve
|
||||
actual lot; original stock, hand limits, bonus and source successor run once.
|
||||
Affected10-file union108/108, types/lint/build and twenty native API profiles
|
||||
pass. Actual390px cache/Black/self-cache/diverted-cache Use retains wallets,
|
||||
one lot and original bonus/round, with no overflow/errors. Direct CHOAM/
|
||||
Ambassador purchases, full combinations, assurance and publication remain open.
|
||||
[Source policy and proof](NEXUS_EMPEROR_RULES.md#recommended-richese-seller-unpaid-purchase--14-october-2026).
|
||||
No saved/deployed room touched; proof receipts remain outside Git. Existing
|
||||
build route-classification/chunk-size warnings remain.
|
||||
|
||||
**Emperor Secret Ally original extra-return continuation — 14 October.**
|
||||
Exactly three now enters native pending revival after one Nexus spend.
|
||||
Requested/returned/denied receipts track real settlement; ordinary force/free
|
||||
|
||||
@@ -1,5 +1,36 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 14 October 2026 — Emperor Nexus original Richese purchase retention
|
||||
|
||||
Connected original positive Richese cache and Advanced Black Market sold lots
|
||||
through uniform winning-buyer Pay/actual eligible Emperor Nexus Use after
|
||||
original Richese Betrayal. Recommended visibly provisional seller-unpaid
|
||||
composition retains the full personally affordable price without bank money,
|
||||
paid invoice or refund. Basic/Advanced cache and legal self-cache work;
|
||||
original self-Black remains blocked. Original Betrayal self-discard leaves no
|
||||
acquisition or Emperor Nexus disposal; bank diversion retains provenance but
|
||||
receives nothing on zero-payment Use.
|
||||
|
||||
Strict version-two purchase receipts preserve source, seller, round, lot,
|
||||
price, physical card and unchanged wallet; old bank receipt remains distinct.
|
||||
Original Truthtrance discard restores the same decision. Actual stock removal,
|
||||
hand limits, native Harkonnen bonus and source successor continue once.
|
||||
Original paid Richese tests/drivers now execute actual public buyer Pay instead
|
||||
of stopping at the new confirmation. Incidental wording/action-copy pins are
|
||||
removed rather than replaced. Minimal bots use the shared private legal offer.
|
||||
|
||||
Affected10-file union108/108 and types/lint/build pass. Twenty actual native
|
||||
API profiles cover Basic/Advanced cache Pay/Use, Advanced Black Market Pay/Use,
|
||||
original Betrayal pass/bank diversion and legal self-cache. Initial throwaway
|
||||
completion predicate incorrectly required no decision at the genuine Black
|
||||
Market declaration successor; corrected to original lot/sale completion.
|
||||
Actual390px four surfaces retain original wallets, deliver one original lot,
|
||||
and finish native Harkonnen bonus/round without overflow/errors. No saved or
|
||||
deployed room touched. Direct CHOAM/Ambassador sources, complete combinations,
|
||||
assurance and publication remain open. Proof receipts stay outside Git;
|
||||
existing build route-classification/chunk-size warnings remain.
|
||||
[Canonical policy/proof](NEXUS_EMPEROR_RULES.md#recommended-richese-seller-unpaid-purchase--14-october-2026).
|
||||
|
||||
## 14 October 2026 — Emperor Nexus original extra-return continuation
|
||||
|
||||
Replaced direct physical transfer with a fixed-three source entering original
|
||||
|
||||
@@ -12,6 +12,17 @@ movement, battle geometry and actual casualties stay unchanged. One original
|
||||
Karama response cancels the entire physical batch and keeps the Nexus spent.
|
||||
[Source, original native proofs and controls](NEXUS_BENE_GESSERIT_RULES.md#recommended-cunning-card-exceptions--14-october-2026).
|
||||
|
||||
## Recommended Emperor Richese seller retention — 14 October 2026
|
||||
|
||||
Original Richese cache and Advanced Black Market now offer uniform buyer Pay
|
||||
after original Richese Betrayal, or actual private Emperor Nexus Use. Use
|
||||
retains the personally affordable full price, leaves seller/bank unpaid and
|
||||
delivers one original card through native bonus/round continuation. Self-cache
|
||||
is legal where originally offered; self-Black remains prohibited. The unpaid
|
||||
seller is a visibly provisional conservative recommendation, not publisher
|
||||
clarification. Direct CHOAM/Ambassador acquisitions remain open.
|
||||
[Source, policy and changed-path proof](NEXUS_EMPEROR_RULES.md#recommended-richese-seller-unpaid-purchase--14-october-2026).
|
||||
|
||||
## Recommended Emperor independent extra return — 14 October 2026
|
||||
|
||||
Secret Ally exact-three now uses original pending revival, with requested/
|
||||
|
||||
@@ -48,7 +48,9 @@ Use the final ordinary purchase price and verify the holder's spendable spice be
|
||||
|
||||
For an ordinary bank-paid auction, the practical result is one purchased card, unchanged buyer spice and a spent Nexus card. No bank-funded replacement payment is printed. The public proof need establish only that the price is held, not disclose excess private spice. These are implementation consequences of the component, not an extra auction rule.
|
||||
|
||||
Special seller payments require a separate ruling: if Richese or another faction would receive that price, is the seller unpaid or paid by the bank? The text only tells the buyer to keep spice. Likewise, it does not expressly decide purchases outside Bidding, such as a card acquisition from an Ambassador. Do not silently route income or broaden a generic purchase hook to those sources.
|
||||
Richese cache and Advanced Black Market now use the recommended **seller-unpaid**
|
||||
composition below. Direct CHOAM markets, Ambassador acquisitions and other
|
||||
purchase sources remain separate; do not broaden a generic acquisition hook.
|
||||
|
||||
### Bounded bank-auction purchase prototype (27 September 2026)
|
||||
|
||||
@@ -59,16 +61,49 @@ spice without ally contribution. The bank receives no spice; the winner keeps
|
||||
that balance, receives the one purchased physical Treachery Card and continues
|
||||
through normal auction follow-ups. One private signed receipt records the
|
||||
original lot, final price and unchanged buyer balance across JSON/SQLite
|
||||
recovery. No Richese/CHOAM seller payment, allied split, expansion, special
|
||||
purchase or generic card-acquisition hook is inferred from this path.
|
||||
recovery. Its classic-only admission remains distinct from the new Richese
|
||||
seller continuation; no allied split or generic acquisition hook is inferred.
|
||||
|
||||
Every winner in this bounded public auction context receives the same payment
|
||||
confirmation, even without the card or Karama. Otherwise a pause only for the
|
||||
concealed holder would disclose Nexus ownership. This is a **provisional
|
||||
privacy-preserving product choice**, not a printed timing rule or a user ruling;
|
||||
the previously asked confirmation-versus-gate question remains open.
|
||||
the recommendation is selected under the user's 14 October decision priority.
|
||||
The normal spice and Karama payment choices remain available where legal.
|
||||
|
||||
|
||||
### Recommended Richese seller-unpaid purchase — 14 October 2026
|
||||
|
||||
Original positive Richese cache and Advanced-only Black Market sales now offer
|
||||
every winning buyer the same Pay confirmation, independently of hidden Nexus
|
||||
face. Only an actual unallied Emperor-card holder, with native Emperor absent
|
||||
and the full price personally affordable, can retain that price. Basic cache
|
||||
and legally offered self-cache are included; original Black Market self-purchase
|
||||
remains prohibited. Allied funding and ordinary bank-purchase admission are
|
||||
unchanged.
|
||||
|
||||
**Provisional recommendation: the seller is unpaid.** The printed card tells
|
||||
the buyer to keep spice and prints no bank reimbursement. Do not invent seller
|
||||
income, bank subsidy, payer, refund or paid-income cancellation when no payment
|
||||
occurred. This is conservative source composition selected under the user's
|
||||
decision priority, not publisher clarification or a numerical user ruling.
|
||||
|
||||
Original Richese Betrayal completes first. Self-purchase forced discard leaves
|
||||
no acquisition and no Emperor Nexus spend. A diverted/all-pass sold source then
|
||||
offers original Pay or Nexus Use: diversion still records its bank destination,
|
||||
but a retained-price purchase transfers no spice to it. Version-two signed
|
||||
receipts bind original seller, source, round, lot, physical card, price and
|
||||
unchanged buyer wallet without comparing historical snapshots to future hands.
|
||||
Original source removal, hand limits, Harkonnen bonus and round successor run
|
||||
once; Truthtrance's actual discard restores the same buyer decision.
|
||||
|
||||
Affected ten-file union108/108 and types/lint/build pass. Actual390px cache,
|
||||
Black Market, self-cache and bank-diverted cache Use keep original wallets,
|
||||
deliver one actual card and continue original native bonus/round controls.
|
||||
No overflow/browser errors; no saved/deployed rooms touched. API proof and
|
||||
phone receipts remain outside Git. Direct CHOAM/Ambassador purchase composition,
|
||||
full combinations, assurance and publication acceptance remain open.
|
||||
|
||||
## Secret Ally: three additional free revivals
|
||||
|
||||
The express additional/beyond-limits wording supplies a separate force allowance rather than spending the holder's ordinary three-force quota. It revives forces, not a leader or Cheap Hero. Recruits doubles free rates and changes the normal limit; it does not double a fixed three-counter card grant. [Printed card](https://boardgamegeek.com/image/7767032/dune-ecaz-and-moritani), [E3, p.11](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=11)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
# Emperor Nexus Secret Ally revival and bank-auction purchase
|
||||
# Emperor Nexus Secret Ally revival and original purchases
|
||||
|
||||
Updated 14 October 2026. **Prototyped**, with **Partial** rules coverage.
|
||||
Revival now uses the independent extra source through original native pending
|
||||
continuations and supported Nexus module envelopes. Bank-auction purchase keeps
|
||||
its distinct classic-only boundary. Public starts and publication remain gated.
|
||||
its distinct classic-only boundary; original Richese cache/Advanced Black Market
|
||||
now have their own seller continuation. Public starts and publication remain gated.
|
||||
|
||||
## Rules and connected behavior
|
||||
|
||||
@@ -58,16 +59,23 @@ Every winner in this bounded public auction context receives the same payment
|
||||
confirmation, including a player with only spice and no Nexus card. Otherwise
|
||||
the holder-only pause would disclose hidden ownership. The existing ordinary
|
||||
spice/Karama payment choices stay available; bots use a legal purchase when
|
||||
owned and otherwise use the ordinary decision. The previously asked
|
||||
confirmation-versus-gate question remains pending; this prototype does not
|
||||
settle it for release.
|
||||
owned and otherwise use the ordinary decision. The recommended confirmation
|
||||
is selected under the user's 14 October decision priority; it does not certify
|
||||
complete release timing.
|
||||
|
||||
Seller-paid Richese/CHOAM transactions, allied split, expansions and other
|
||||
Secret Ally purchase sources remain gated. The separate normal-auction forced
|
||||
Emperor-payment Betrayal now has a connected
|
||||
[provisional full-price funding path](NEXUS_EMPEROR_RULES.md#recommended-original-full-price-funding--14-october-2026).
|
||||
The [Secret Ally source contract](NEXUS_EMPEROR_RULES.md#secret-ally-retain-the-actual-purchase-price)
|
||||
retains its distinct seller-payment boundary.
|
||||
Original Richese cache and Advanced Black Market now connect a separate
|
||||
uniform winning-buyer confirmation after the original Richese Betrayal window.
|
||||
Use spends the actual Nexus and delivers one original lot with unchanged buyer
|
||||
spice and **no seller/bank reimbursement**. The visible seller-unpaid policy is
|
||||
provisional conservative composition, not publisher clarification. Original
|
||||
self-cache is supported; Black Market self-purchase remains blocked. Actual
|
||||
Truthtrance discard, hand limits, native Harkonnen bonus and source successor
|
||||
continue once. Version-two receipts retain original source/payment proof.
|
||||
[Policy and changed-path evidence](NEXUS_EMPEROR_RULES.md#recommended-richese-seller-unpaid-purchase--14-october-2026).
|
||||
|
||||
Direct CHOAM markets, Ambassador/other acquisitions, allied split and full
|
||||
combinations remain open. Normal-auction forced Emperor-payment Betrayal has
|
||||
its separate [provisional full-price funding path](NEXUS_EMPEROR_RULES.md#recommended-original-full-price-funding--14-october-2026).
|
||||
|
||||
## Prototype entry and evidence
|
||||
|
||||
|
||||
+15
-1
@@ -66,6 +66,20 @@ These decisions do not open public module/faction or complete-mode gates.
|
||||
|
||||
These decisions do not open public module/faction or complete-mode gates.
|
||||
|
||||
## Recommended Emperor Richese seller retention — 14 October 2026
|
||||
|
||||
Choose the conservative **seller-unpaid** interpretation: the buyer keeps the
|
||||
full personally affordable original price and no bank reimbursement is printed.
|
||||
Original Richese cache/Advanced Black Market now use uniform buyer confirmation
|
||||
after original Richese Betrayal. Self-discard precludes acquisition/Nexus spend;
|
||||
bank diversion preserves provenance but receives nothing on zero-payment Use.
|
||||
Original stock, hand limits, bonus and successor continue. Basic/Advanced cache,
|
||||
legal self-cache and Advanced Black Market are covered; self-Black remains
|
||||
prohibited. Direct CHOAM/Ambassador purchases and full combinations stay open.
|
||||
Visibly provisional source composition selected under decision priority, not
|
||||
publisher clarification or a separately answered numerical ruling.
|
||||
[Canonical policy/proof](NEXUS_EMPEROR_RULES.md#recommended-richese-seller-unpaid-purchase--14-october-2026).
|
||||
|
||||
## Recommended Emperor independent extra return — 14 October 2026
|
||||
|
||||
The conservative composition keeps exactly three additional physical forces
|
||||
@@ -601,7 +615,7 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t
|
||||
| Moritani Secret Ally battle timing | The printed after-loss retention is optional and the Nexus card remains secret. The connected prototype offers the same choice to every loser when Moritani is absent and at least one publicly revealed played card is retainable; a loser with no eligible public card has no useful choice, independent of hidden custody. Only an unallied holder may spend the physical card. This prompt is a privacy-preserving product inference, not publisher timing text. Native Moritani alliance retention and its Karama response remain separate; no generic Secret Ally counter is invented. | [Nexus retention boundary](NEXUS_CARD_RULES.md#moritani-secret-ally-retention-timing) |
|
||||
| Ecaz Secret Ally traitor inquiry | The printed panel asks one selected player whether they hold any of the holder's leaders as Traitors, without identifying a leader; it specifies neither native-versus-controlled scope nor answer audience. The connected prototype chooses native printed faction leaders (including dead/captured), ordinary Traitor Cards rather than Face Dancers, and a holder-only historical yes/no. This is a privacy-preserving product interpretation, **not** a publisher ruling. Foreign Ghola/captive scope and table-wide disclosure remain release boundaries. | [Ecaz inquiry source and bounded runtime](NEXUS_ECAZ_RULES.md#bounded-secret-ally-traitor-inquiry-runtime) |
|
||||
| Ecaz native Nexus Cunning timing | The printed Cunning takes Duke for this turn even from capture, Tanks or Ghola, without a phase. The quiet Battle prototype now covers living control or set-aside Tanks. Tanks preserve deaths and reset use under a provisional independent-resurrection policy; this is not a numerical user ruling. Captured/Ghola return destinations and Advanced Harkonnen remain guarded; ordinary Ambassador and paid-revival custody are unchanged. | [Source and bounded runtime](NEXUS_ECAZ_RULES.md#bounded-native-cunning-duke-vidal) |
|
||||
| Emperor Secret Ally purchase timing | The bounded normal bank-auction prototype chooses a uniform winner payment confirmation in classic-faction Nexus-only games with Emperor absent, including otherwise sole spice payments. This prevents a holder-only pause from exposing hidden card eligibility. It is a provisional product privacy choice, **not** a user or publisher ruling; the previously asked confirmation-versus-gate question remains open. Seller-paid/allied purchases and other sources stay gated. | [Source contract and bounded prototype](NEXUS_EMPEROR_RULES.md#secret-ally-retain-the-actual-purchase-price), [runtime](NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md#bank-auction-purchase-alternative) |
|
||||
| Emperor Secret Ally purchase timing | Uniform winning-buyer confirmation prevents a holder-only pause exposing hidden eligibility. The classic bank-auction boundary remains separate; original Richese cache/Advanced Black Market now use the recommended provisional seller-unpaid policy after original Richese Betrayal. The user's 14 October decision priority selects the recommendation, not a publisher timing ruling. Allied and other purchase sources remain open. | [Purchase policy](NEXUS_EMPEROR_RULES.md#recommended-richese-seller-unpaid-purchase--14-october-2026) |
|
||||
| Discovery prototype inferences | Free entry uses storm order and ordinary source-storm restrictions; these are general-rule inferences. Great Maker currently follows ordinary first-turn worm skipping and Sandtrout suppression; an exhausted token type creates no replacement token. These preserve current source interpretation and physical inventory but are not separately confirmed rulings or complete combined-module certification. | [Interpretations for refinement](DISCOVERY_PROTOTYPE.md#interpretations-retained-for-refinement) |
|
||||
| Other Homeworld combinations | Population/force splitting, removal custody, replacement shortages, income accounting and placement ordering remain local to their detailed contracts. | [Homeworld rules](HOMEWORLD_RULES.md), [occupation](HOMEWORLD_OCCUPATION_RULES.md) and the effect documents above |
|
||||
|
||||
|
||||
@@ -110,6 +110,7 @@ const decisions = {
|
||||
nexusFremenCunningOffer: true,
|
||||
nexusFremenProtection: true,
|
||||
nexusEmperorPayment: true,
|
||||
nexusEmperorSellerPurchase: true,
|
||||
nexusFremenCunningRide: true,
|
||||
moritaniRetention: true,
|
||||
moritaniSpecialKarama: true,
|
||||
|
||||
@@ -101,8 +101,9 @@ export function auctionEmperorIncomeAmount(
|
||||
: sale.richeseContribution ?? 0;
|
||||
}
|
||||
|
||||
/** Deterministic paid-sale continuation only. A returned response, Richese
|
||||
* transition or normal phase-end is a boundary, not a simulated future action.
|
||||
/** Deterministic original-sale continuation, including retained-price free lots.
|
||||
* A returned response, Richese transition or normal phase-end is a boundary,
|
||||
* not a simulated future action.
|
||||
* Never publish this internal quote: returned lot cards remain private. */
|
||||
export function quoteAuctionContinuation(
|
||||
input: AuctionContinuationInput,
|
||||
@@ -236,12 +237,12 @@ function calculate(
|
||||
requireAuction(
|
||||
s &&
|
||||
['cache', 'blackMarket'].includes(s.origin) &&
|
||||
s.free === false &&
|
||||
typeof s.free === 'boolean' &&
|
||||
count(s.amount) &&
|
||||
seated(s.winner) &&
|
||||
s.seller &&
|
||||
seated(s.seller)?.faction === 'richese',
|
||||
'The paid Richese sale receipt is malformed.',
|
||||
'The original Richese sale receipt is malformed.',
|
||||
);
|
||||
requireAuction(
|
||||
lot &&
|
||||
@@ -272,6 +273,20 @@ function calculate(
|
||||
: ['first', 'last'].includes(String(round.position))),
|
||||
'The paid Richese lot has a stale declaration round.',
|
||||
);
|
||||
requireAuction(
|
||||
lot.source !== 'blackMarket' || (input.advanced && s.seller !== s.winner),
|
||||
'Black Market requires an Advanced sale to another buyer; self-purchase awaits a payment and sale-counting ruling.',
|
||||
);
|
||||
requireAuction(
|
||||
!s.free || (
|
||||
s.amount > 0 && s.richeseContribution === undefined &&
|
||||
s.spiceBankerIncomePayment === undefined && s.bureaucratPayment === undefined &&
|
||||
s.bureaucratPaymentEvent === undefined &&
|
||||
(s.occupiedContributions === undefined ||
|
||||
s.occupiedContributions.every(contribution => contribution.amount === 0))
|
||||
),
|
||||
'A free Richese purchase cannot carry an actual payment, donor invoice or generated income.',
|
||||
);
|
||||
sale = { ...s, legacy: false };
|
||||
cardId = lot.cardId;
|
||||
}
|
||||
@@ -365,7 +380,7 @@ function calculate(
|
||||
next: AuctionContinuationQuote['next'],
|
||||
): AuctionContinuationQuote => ({ sale, steps, next });
|
||||
if (stage === 'sale') {
|
||||
if (sale.origin !== 'normal' && sale.seller !== winner.id) {
|
||||
if (!sale.free && sale.origin !== 'normal' && sale.seller !== winner.id) {
|
||||
if (sale.recipient !== 'bank') {
|
||||
const seller = seated(sale.seller!)!;
|
||||
const amount = sale.amount - (sale.richeseContribution ?? 0);
|
||||
|
||||
@@ -2693,6 +2693,9 @@ function policyActions(g: GameView): Action[] {
|
||||
if (d.kind === 'nexusEmperorPayment')
|
||||
return [{ type: 'nexusEmperorPayment', event: d.event,
|
||||
use: !!g.nexusEmperorPayment?.canUse && !g.nexusEmperorPayment.blocked }];
|
||||
if (d.kind === 'nexusEmperorSellerPurchase')
|
||||
return [{ type: 'nexusEmperorSellerPurchase', event: d.event,
|
||||
use: !!g.nexusEmperorSellerPurchase?.canUse && !g.nexusEmperorSellerPurchase.blocked }];
|
||||
if (d.kind === 'nexusFremenCunningOffer')
|
||||
return [{ type: 'decision', event: d.event,
|
||||
accept: g.nexusCards?.card === 'fremen' && me.faction === 'fremen' &&
|
||||
|
||||
+156
-25
@@ -45,7 +45,7 @@ import { quoteNexusChoamBetrayal, validateNexusChoamBetrayal, nexusChoamBetrayal
|
||||
import { quoteEcazInquiry, ecazInquiryAnswer, ecazInquiryHistory, ecazInquirySignature, validateEcazInquiry, type EcazInquiryReceipt } from './nexus-ecaz-inquiry';
|
||||
import { nexusCleanPlayBlocked } from './nexus-play-boundary';
|
||||
import { EMPEROR_NEXUS_REVIVALS, emperorNexusModeSupported, quoteEmperorNexusRevival, createEmperorNexusRevival, settleEmperorNexusRevival, validateEmperorNexusRevival, type NexusEmperorRevival } from './nexus-emperor-secret-ally';
|
||||
import { emperorNexusPurchaseEvent, quoteEmperorNexusPurchase, signEmperorNexusPurchase, validateEmperorNexusPurchase, type EmperorNexusPurchase } from './nexus-emperor-purchase';
|
||||
import { emperorNexusPurchaseEvent, quoteEmperorNexusPurchase, signEmperorNexusPurchase, validateEmperorNexusPurchase, emperorNexusSellerPurchaseModeSupported, quoteEmperorNexusSellerPurchase, createEmperorNexusSellerPurchase, type EmperorNexusPurchase } from './nexus-emperor-purchase';
|
||||
import { createEmperorPaymentReceipt, validateEmperorPaymentReceipt, emperorPaymentUseBlock, emperorPaymentSourceQuote, requiredEmperorPaymentResponders, emperorPaymentReceiptSignature, type EmperorPaymentReceipt } from './nexus-emperor-payment';
|
||||
import { FREMEN_NEXUS_FREE_FORCES, fremenNexusRevivalOffer, createFremenNexusRevival, upgradeFremenNexusRevival, validateFremenNexusRevival, type FremenNexusRevival } from './nexus-fremen-revival';
|
||||
import { captureFremenCunningOccurrence, quoteFremenCunning, quoteFremenCunningRide, validateFremenCunningSelection, type FremenCunningOccurrence, type FremenCunningRideAuthorization, type FremenCunningSelection } from './nexus-fremen-cunning';
|
||||
@@ -934,6 +934,7 @@ export type Decision =
|
||||
| { kind: 'guildTiming'; player: string; next: string; following: string }
|
||||
| { kind: 'auctionPayment'; player: string }
|
||||
| { kind: 'nexusEmperorPayment'; player: string; event: string }
|
||||
| { kind: 'nexusEmperorSellerPurchase'; player: string; event: string }
|
||||
| {
|
||||
kind: 'advisor';
|
||||
player: string;
|
||||
@@ -1201,6 +1202,12 @@ export type Game = {
|
||||
/** Original normal-auction obligation; neutral acknowledgements precede funding. */
|
||||
nexusEmperorPayment?: { sequence: number; current: string | null; completed: EmperorPaymentReceipt[] };
|
||||
pendingNexusEmperorPayment?: { receipt: EmperorPaymentReceipt } | null;
|
||||
/** Uniform winning-buyer confirmation bound to the original unpaid seller lot. */
|
||||
pendingNexusEmperorSellerPurchase?: {
|
||||
event: string; turn: number; winner: string; round: string;
|
||||
lot: RicheseAuction; quote: Extract<RicheseSettlementQuote, {kind:'sold'}>;
|
||||
bank: boolean; signature: string;
|
||||
} | null;
|
||||
/** Explicit development profile; never enabled by a public start or player action. */
|
||||
kullPreview?: boolean;
|
||||
nexusKullPreview?: boolean;
|
||||
@@ -2789,12 +2796,15 @@ function nexusEmperorSecretIntegrity(g:Game) {
|
||||
if (purchases === undefined)
|
||||
requireRule(purchaseEvents === undefined,'Emperor Nexus has lost its saved purchase history.');
|
||||
else {
|
||||
requireRule(g.nexusCards?.cards && emperorNexusModeSupported(g) && !g.ecazTreachery && !g.sandtrout &&
|
||||
requireRule(g.nexusCards?.cards &&
|
||||
Array.isArray(purchases) && purchases.length > 0 && Array.isArray(purchaseEvents) &&
|
||||
purchases.length === purchaseEvents.length && new Set(purchaseEvents).size === purchaseEvents.length,
|
||||
'Emperor Nexus purchase has lost its physical module or independent use markers.');
|
||||
for (const [index,record] of purchases.entries()) {
|
||||
nexusRule(() => validateEmperorNexusPurchase(g,record));
|
||||
if (!('version' in record))
|
||||
requireRule(emperorNexusModeSupported(g) && !g.ecazTreachery && !g.sandtrout,
|
||||
'A normal-bank Emperor purchase lost its original supported profile.');
|
||||
requireRule(record.sequence === index && purchaseEvents[index] === record.event,
|
||||
'Emperor Nexus purchase has duplicated or reordered a completed use.');
|
||||
}
|
||||
@@ -11756,17 +11766,32 @@ function richeseBetrayalIntegrity(g: Game): void {
|
||||
const sale = g.currentAuctionSale;
|
||||
const completed = cursor.completed.at(-1);
|
||||
if (completed && completed.lot === g.richeseAuction?.event) {
|
||||
const waiting = g.pendingNexusEmperorSellerPurchase;
|
||||
requireRule(g.status === 'playing' && g.phase === 3 && completed.turn === g.turn &&
|
||||
completed.round === g.richeseBidding?.event &&
|
||||
!(completed.stage === 'used' && completed.invoice.kind === 'purchase') &&
|
||||
sale && sale.winner === completed.invoice.buyer && sale.seller === completed.invoice.target &&
|
||||
sale.origin === completed.invoice.source && sale.amount === completed.invoice.price && sale.free === false &&
|
||||
sale.recipient === (completed.stage === 'used' ? 'bank' : undefined),
|
||||
'The completed Richese reaction lost or reopened its original paid-sale continuation.');
|
||||
const responses = savedNoFieldResponses(g).filter(response =>
|
||||
response.kind === 'emperorIncome' || response.kind === 'harkonnenBonus');
|
||||
requireRule(responses.length === 1,'The completed Richese sale lost its native income or bonus continuation.');
|
||||
currentAuctionContinuationQuote(g,{kind:'cancel',response:responses[0]});
|
||||
!(completed.stage === 'used' && completed.invoice.kind === 'purchase'),
|
||||
'The completed Richese reaction lost its original source.');
|
||||
if (waiting) {
|
||||
requireRule(!sale && waiting.round === completed.round && waiting.lot.event === completed.lot &&
|
||||
waiting.winner === completed.invoice.buyer && waiting.lot.owner === completed.invoice.target &&
|
||||
waiting.lot.source === completed.invoice.source && waiting.quote.amount === completed.invoice.price &&
|
||||
waiting.bank === (completed.stage === 'used'),
|
||||
'The original Richese reaction lost its suspended winning-buyer confirmation.');
|
||||
} else {
|
||||
const retained = sale?.free && g.nexusEmperorPurchaseHistory?.some(receipt =>
|
||||
'version' in receipt && receipt.version === 2 && receipt.lot === completed.lot &&
|
||||
receipt.round === completed.round && receipt.owner === completed.invoice.buyer &&
|
||||
receipt.seller === completed.invoice.target && receipt.origin === completed.invoice.source &&
|
||||
receipt.price === completed.invoice.price);
|
||||
requireRule(sale && sale.winner === completed.invoice.buyer && sale.seller === completed.invoice.target &&
|
||||
sale.origin === completed.invoice.source && sale.amount === completed.invoice.price &&
|
||||
(!sale.free || retained) && sale.recipient === (completed.stage === 'used' ? 'bank' : undefined),
|
||||
'The completed Richese reaction lost or reopened its original sale continuation.');
|
||||
const responses = savedNoFieldResponses(g).filter(response =>
|
||||
response.kind === 'emperorIncome' || response.kind === 'harkonnenBonus');
|
||||
requireRule(responses.length === 1,'The completed Richese sale lost its native income or bonus continuation.');
|
||||
currentAuctionContinuationQuote(g,{kind:'cancel',response:responses[0]});
|
||||
}
|
||||
}
|
||||
if (sale?.recipient) {
|
||||
const receipt = cursor.completed.at(-1);
|
||||
@@ -11859,19 +11884,104 @@ function settleRicheseLot(g: Game) {
|
||||
if (beginRicheseBetrayal(g,quote)) return;
|
||||
settleRicheseSoldLot(g,quote,false);
|
||||
}
|
||||
function settleRicheseSoldLot(g: Game, quote: Extract<RicheseSettlementQuote,{kind:'sold'}>, bank: boolean) {
|
||||
const EMPEROR_SELLER_POLICY = 'Provisional seller policy: retain your own full original price after proving it. The seller stays unpaid; no Spice Bank reimbursement, payer invoice or payment-derived income is created. Original card acquisition and native bonus/lot continuation still resolve.';
|
||||
function emperorSellerSource(frame: NonNullable<Game['pendingNexusEmperorSellerPurchase']>) {
|
||||
return { round: frame.round, lot: frame.lot, quote: frame.quote, bank: frame.bank };
|
||||
}
|
||||
function emperorSellerSourceSignature(g: Game, frame: NonNullable<Game['pendingNexusEmperorSellerPurchase']>) {
|
||||
const winner = getPlayer(g, frame.winner);
|
||||
return JSON.stringify([frame.event, frame.turn, frame.winner, frame.round,
|
||||
frame.lot, frame.quote, frame.bank, g.richeseBidding?.event,
|
||||
g.richeseBidding?.stage, g.richeseAuction, g.richeseFunding?.[frame.winner],
|
||||
aidFor(g, winner), winner.spice, winner.ally,
|
||||
getPlayer(g, frame.lot.owner).ally, g.nexusEmperorPurchaseHistory?.length ?? 0]);
|
||||
}
|
||||
function nexusEmperorSellerIntegrity(g: Game): void {
|
||||
const frame = g.pendingNexusEmperorSellerPurchase;
|
||||
const continuation = g.pendingTreacheryDiscard?.continuation;
|
||||
const decision = g.decision?.kind === 'nexusEmperorSellerPurchase' ? g.decision :
|
||||
continuation?.kind === 'truthtranceDiscard' ? continuation.resume.decision : null;
|
||||
if (!frame) {
|
||||
requireRule(decision?.kind !== 'nexusEmperorSellerPurchase',
|
||||
'The original seller purchase lost its current winning-buyer confirmation.');
|
||||
return;
|
||||
}
|
||||
requireRule(Object.keys(frame).sort().join(',') === 'bank,event,lot,quote,round,signature,turn,winner' &&
|
||||
frame.turn === g.turn && typeof frame.event === 'string' && frame.event.length > 0 &&
|
||||
typeof frame.bank === 'boolean' && typeof frame.signature === 'string' &&
|
||||
emperorNexusSellerPurchaseModeSupported(g) && !g.pendingRicheseBetrayal &&
|
||||
decision?.kind === 'nexusEmperorSellerPurchase' && decision.player === frame.winner &&
|
||||
decision.event === frame.event && g.active === frame.winner &&
|
||||
frame.signature === emperorSellerSourceSignature(g, frame),
|
||||
'The original seller purchase lost its immutable lot, price, funding or winning buyer.');
|
||||
const quote = nexusRule(() => quoteEmperorNexusSellerPurchase(g, emperorSellerSource(frame), frame.winner));
|
||||
requireRule(quote.event === frame.event &&
|
||||
JSON.stringify(frame.quote) === JSON.stringify(richeseSettlementQuote(g)),
|
||||
'The original seller purchase lost its exact pre-acquisition source.');
|
||||
}
|
||||
function nexusEmperorSellerOffer(g: Game, id: string) {
|
||||
const frame = g.pendingNexusEmperorSellerPurchase;
|
||||
if (!frame || g.decision?.kind !== 'nexusEmperorSellerPurchase' ||
|
||||
g.decision.player !== id || frame.winner !== id) return null;
|
||||
const quote = nexusRule(() => quoteEmperorNexusSellerPurchase(g, emperorSellerSource(frame), id));
|
||||
const blocked = g.truthtrance || pendingNexusTraitors(g) || g.pendingTreacheryDiscard
|
||||
? 'Finish the current question or private return before confirming this purchase.'
|
||||
: quote.blocked;
|
||||
return { ...quote, blocked, canUse: !blocked, policy: EMPEROR_SELLER_POLICY };
|
||||
}
|
||||
function beginNexusEmperorSellerPurchase(g: Game, quote: Extract<RicheseSettlementQuote, {kind:'sold'}>, bank: boolean): boolean {
|
||||
if (quote.amount <= 0 || !emperorNexusSellerPurchaseModeSupported(g)) return false;
|
||||
requireRule(!g.pendingNexusEmperorSellerPurchase && !g.pendingRicheseBetrayal &&
|
||||
!g.response && !g.decision && !g.truthtrance && !g.phaseOpening &&
|
||||
!g.pendingKarama && !g.pendingTreacheryDiscard && !pendingNexusTraitors(g),
|
||||
'Finish the original reaction before confirming the seller purchase.');
|
||||
const input = { round: g.richeseBidding!.event, lot: g.richeseAuction!, quote, bank };
|
||||
const offer = nexusRule(() => quoteEmperorNexusSellerPurchase(g, input, quote.winner));
|
||||
const frame: NonNullable<Game['pendingNexusEmperorSellerPurchase']> = {
|
||||
event: offer.event, turn: g.turn, winner: quote.winner, round: input.round,
|
||||
lot: structuredClone(input.lot), quote, bank, signature: '',
|
||||
};
|
||||
frame.signature = emperorSellerSourceSignature(g, frame);
|
||||
g.pendingNexusEmperorSellerPurchase = frame;
|
||||
g.active = frame.winner;
|
||||
g.decision = { kind: 'nexusEmperorSellerPurchase', player: frame.winner, event: frame.event };
|
||||
return true;
|
||||
}
|
||||
function decideNexusEmperorSellerPurchase(g: Game, p: Player, action: Action): void {
|
||||
const frame = g.pendingNexusEmperorSellerPurchase!;
|
||||
requireRule(action.type === 'nexusEmperorSellerPurchase' && action.event === frame.event &&
|
||||
typeof action.use === 'boolean' && Object.keys(action).sort().join(',') === 'event,type,use' &&
|
||||
p.id === frame.winner && g.decision?.kind === 'nexusEmperorSellerPurchase' &&
|
||||
g.decision.player === p.id && !g.truthtrance && !pendingNexusTraitors(g),
|
||||
'Confirm only your current original seller purchase with Pay or Use.');
|
||||
nexusEmperorSellerIntegrity(g);
|
||||
const receipt = action.use
|
||||
? nexusRule(() => createEmperorNexusSellerPurchase(g, emperorSellerSource(frame)))
|
||||
: null;
|
||||
if (receipt) {
|
||||
g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players));
|
||||
(g.nexusEmperorPurchaseHistory ??= []).push(receipt);
|
||||
(g.nexusEmperorPurchaseEvents ??= []).push(receipt.event);
|
||||
log(g, `${p.name} spent Emperor Nexus after proving the original ${receipt.price}-spice seller price. They retain their spice; the seller stays unpaid and no bank subsidy or payment invoice is created. Provisional seller interpretation.`);
|
||||
}
|
||||
g.pendingNexusEmperorSellerPurchase = null;
|
||||
g.decision = null;
|
||||
settleRicheseSoldLot(g, frame.quote, frame.bank, !!receipt, true);
|
||||
}
|
||||
function settleRicheseSoldLot(g: Game, quote: Extract<RicheseSettlementQuote,{kind:'sold'}>, bank: boolean, free = false, confirmed = false) {
|
||||
if (!confirmed && beginNexusEmperorSellerPurchase(g, quote, bank)) return;
|
||||
const lot = g.richeseAuction!, owner = getPlayer(g,lot.owner);
|
||||
const winner = getPlayer(g, quote.winner), card = quote.card;
|
||||
const richeseContribution = g.advanced && winner.id !== owner.id && winner.ally === owner.id
|
||||
const richeseContribution = !free && g.advanced && winner.id !== owner.id && winner.ally === owner.id
|
||||
? quote.allyPayment : 0;
|
||||
const bankAmount = bank || winner.id === owner.id ? quote.amount : richeseContribution;
|
||||
const bankAmount = free ? 0 : bank || winner.id === owner.id ? quote.amount : richeseContribution;
|
||||
const emperor = byFaction(g, 'emperor');
|
||||
const incomePayment = g.spiceBankerIncomePreview && bankAmount > 0 ? bankerIncomePayment(g, 'auction',
|
||||
bank || winner.id === owner.id
|
||||
? [{payer:winner.id,amount:quote.ownPayment,recipient:bank || !emperor ? 'bank' : 'player'},
|
||||
...(quote.allyPayment ? [{payer:winner.ally!,amount:quote.allyPayment,recipient:bank || !emperor ? 'bank' as const : 'player' as const}] : [])]
|
||||
: [{payer:owner.id,amount:richeseContribution,recipient:emperor ? 'player' : 'bank'}]) : undefined;
|
||||
payWithAlly(g, winner, quote.amount, quote.allyPayment);
|
||||
if (!free) payWithAlly(g, winner, quote.amount, quote.allyPayment);
|
||||
g.richeseFunding = {};
|
||||
if (lot.source === 'cache')
|
||||
g.richeseCache = g.richeseCache!.filter((c) => c.id !== card.id);
|
||||
@@ -11879,29 +11989,29 @@ function settleRicheseSoldLot(g: Game, quote: Extract<RicheseSettlementQuote,{ki
|
||||
winner.hand.push(card);
|
||||
g.currentAuctionSale = {
|
||||
...(g.homeworldOccupationPreview ? { occupiedEvent: lot.event,
|
||||
occupiedContributions: [{ payer: winner.id, amount: quote.ownPayment },
|
||||
occupiedContributions: free ? [] : [{ payer: winner.id, amount: quote.ownPayment },
|
||||
...(quote.allyPayment ? [{ payer: winner.ally!, amount: quote.allyPayment }] : [])] } : {}),
|
||||
...(richeseContribution ? {richeseContribution} : {}),
|
||||
...(incomePayment ? {spiceBankerIncomePayment:incomePayment} : {}),
|
||||
winner: winner.id,
|
||||
amount: quote.amount,
|
||||
free: false,
|
||||
free,
|
||||
origin: lot.source,
|
||||
seller: owner.id,
|
||||
...(bank ? {recipient:'bank' as const} : {}),
|
||||
...stampAuctionBureaucrat(g,winner,quote.amount,quote.allyPayment,owner.id),
|
||||
...(!free ? stampAuctionBureaucrat(g,winner,quote.amount,quote.allyPayment,owner.id) : {}),
|
||||
};
|
||||
if (bank || !emperor) awardBankerIncome(g, incomePayment);
|
||||
log(
|
||||
g,
|
||||
`${winner.name} bought ${lot.source === 'cache' ? card.name : 'the concealed Black Market card'} for ${quote.amount} spice. The funded bid was paid automatically.`,
|
||||
`${winner.name} bought ${lot.source === 'cache' ? card.name : 'the concealed Black Market card'} for ${quote.amount} spice.${free ? ' Emperor Nexus retained the proven price; no seller or bank payment occurred.' : ' The funded bid was paid automatically.'}`,
|
||||
{ faction: winner.faction, name: 'Auction payment' },
|
||||
);
|
||||
const ixians = byFaction(g, 'ixians');
|
||||
if (lot.source === 'blackMarket' && ixians && winner.ally === ixians.id) {
|
||||
g.pendingIxAlly = { player: winner.id, card: card.id, free: false };
|
||||
g.pendingIxAlly = { player: winner.id, card: card.id, free };
|
||||
g.decision = { kind: 'ixAllyCard', player: winner.id };
|
||||
} else continueAuctionSale(g, false);
|
||||
} else continueAuctionSale(g, free);
|
||||
}
|
||||
function beginRicheseLot(
|
||||
g: Game,
|
||||
@@ -28599,6 +28709,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
state = migratePrintedKullCounter(state);
|
||||
richeseBetrayalIntegrity(state);
|
||||
nexusEmperorPaymentIntegrity(state);
|
||||
nexusEmperorSellerIntegrity(state);
|
||||
kullIntegrity(state);
|
||||
advancedPreviewIntegrity(state);
|
||||
harkonnenExchangeIntegrity(state);
|
||||
@@ -28689,9 +28800,10 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
if (g.pendingNexusIxianReplacement) return g;
|
||||
if (g.pendingGuildBetrayal) {guildBetrayalIntegrity(g); return g;}
|
||||
if (g.pendingRicheseBetrayal) {richeseBetrayalIntegrity(g);return g;}
|
||||
if (g.pendingNexusEmperorPayment) {
|
||||
if (g.pendingNexusEmperorPayment || g.pendingNexusEmperorSellerPurchase) {
|
||||
finishActionContinuations(g);
|
||||
nexusEmperorPaymentIntegrity(g);
|
||||
nexusEmperorSellerIntegrity(g);
|
||||
return g;
|
||||
}
|
||||
if (g.pendingKull) {
|
||||
@@ -28811,6 +28923,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
richesePairIntegrity(g);
|
||||
richeseBetrayalIntegrity(g);
|
||||
nexusEmperorPaymentIntegrity(g);
|
||||
nexusEmperorSellerIntegrity(g);
|
||||
marketGholaIntegrity(g);
|
||||
homeworldRule(() => homeworldGameIntegrity(g));
|
||||
homeworldBattleLossIntegrity(g);
|
||||
@@ -28841,6 +28954,7 @@ function finishActionContinuations(g: Game) {
|
||||
if (g.pendingTreacheryDiscard?.reaction) return;
|
||||
requireRule(!g.pendingTreacheryDiscard, 'The automatic discard chain did not finish.');
|
||||
if (g.pendingNexusEmperorPayment) return;
|
||||
if (g.pendingNexusEmperorSellerPurchase) return;
|
||||
if (pendingNexusTraitors(g)) return;
|
||||
observeOccupation(g);
|
||||
resumeHomeworldRevivalReturn(g);
|
||||
@@ -28901,6 +29015,7 @@ function settleAutomaticContinuations(g: Game) {
|
||||
if (g.pendingGuildBetrayal) return;
|
||||
if (g.pendingRicheseBetrayal) return;
|
||||
if (g.pendingNexusEmperorPayment) return;
|
||||
if (g.pendingNexusEmperorSellerPurchase) return;
|
||||
if (g.pendingKull?.stage === 'offer') return;
|
||||
if (pendingNexusTraitors(g)) return;
|
||||
if (g.pendingNullentropy) return;
|
||||
@@ -28911,6 +29026,7 @@ function settleAutomaticContinuations(g: Game) {
|
||||
if (g.pendingNexusIxianReplacement) return;
|
||||
if (g.pendingRicheseBetrayal) return;
|
||||
if (g.pendingNexusEmperorPayment) return;
|
||||
if (g.pendingNexusEmperorSellerPurchase) return;
|
||||
syncTupileSlots(g);
|
||||
prepareTupileCleanup(g);
|
||||
if (g.pendingHomeworldTupileCleanup || tupileSourceBlock(g)) return;
|
||||
@@ -28970,6 +29086,7 @@ function settleAutomaticContinuations(g: Game) {
|
||||
if (g.pendingGuildBetrayal) return;
|
||||
if (g.pendingRicheseBetrayal) return;
|
||||
if (g.pendingNexusEmperorPayment) return;
|
||||
if (g.pendingNexusEmperorSellerPurchase) return;
|
||||
// Opposing/automatic consequences can release a now-impossible promise;
|
||||
// they are not the original actor voluntarily spending a promised resource.
|
||||
reconcileBattlePromises(g);
|
||||
@@ -28994,6 +29111,7 @@ export function normalizeAutomaticGame(state: Game): Game {
|
||||
state = migratePrintedKullCounter(state);
|
||||
richeseBetrayalIntegrity(state);
|
||||
nexusEmperorPaymentIntegrity(state);
|
||||
nexusEmperorSellerIntegrity(state);
|
||||
kullIntegrity(state);
|
||||
advancedPreviewIntegrity(state);
|
||||
harkonnenExchangeIntegrity(state);
|
||||
@@ -29034,9 +29152,10 @@ export function normalizeAutomaticGame(state: Game): Game {
|
||||
if (g.pendingNexusIxianReplacement) return g;
|
||||
if (g.pendingGuildBetrayal) return g;
|
||||
if (g.pendingRicheseBetrayal) return g;
|
||||
if (g.pendingNexusEmperorPayment) {
|
||||
if (g.pendingNexusEmperorPayment || g.pendingNexusEmperorSellerPurchase) {
|
||||
finishActionContinuations(g);
|
||||
nexusEmperorPaymentIntegrity(g);
|
||||
nexusEmperorSellerIntegrity(g);
|
||||
return g;
|
||||
}
|
||||
if (g.pendingKull?.stage === 'offer') return g;
|
||||
@@ -29070,6 +29189,7 @@ export function normalizeAutomaticGame(state: Game): Game {
|
||||
richesePairIntegrity(g);
|
||||
richeseBetrayalIntegrity(g);
|
||||
nexusEmperorPaymentIntegrity(g);
|
||||
nexusEmperorSellerIntegrity(g);
|
||||
marketGholaIntegrity(g);
|
||||
prepareTupileCleanup(g);
|
||||
homeworldRule(() => homeworldGameIntegrity(g));
|
||||
@@ -29105,14 +29225,16 @@ function applyActionInner(
|
||||
'Finish the original occupied Tupile hand cleanup before another action.');
|
||||
ecazTreacheryIntegrity(state);
|
||||
const g = structuredClone(state);
|
||||
if (g.pendingNexusEmperorPayment && !g.truthtrance && !pendingNexusTraitors(g)) {
|
||||
if ((g.pendingNexusEmperorPayment || g.pendingNexusEmperorSellerPurchase) &&
|
||||
!g.truthtrance && !pendingNexusTraitors(g)) {
|
||||
const p = getPlayer(g, id);
|
||||
const opensTruth = action?.type === 'card' && !action.mode &&
|
||||
p.hand.some(card => card.id === action.card && (card.effect === 'truthtrance' ||
|
||||
Array.isArray(action.shrineTruthtrance) && action.shrineTruthtrance.includes(card.id)));
|
||||
if (!opensTruth) {
|
||||
if (action?.type === 'advanceBots') return g;
|
||||
decideNexusEmperorPayment(g, p, action);
|
||||
if (g.pendingNexusEmperorSellerPurchase) decideNexusEmperorSellerPurchase(g, p, action);
|
||||
else decideNexusEmperorPayment(g, p, action);
|
||||
return g;
|
||||
}
|
||||
}
|
||||
@@ -29295,6 +29417,11 @@ function applyActionInner(
|
||||
decideNexusEmperorPayment(g, p, action);
|
||||
return g;
|
||||
}
|
||||
if (g.pendingNexusEmperorSellerPurchase) {
|
||||
if (action.type === 'advanceBots') return g;
|
||||
decideNexusEmperorSellerPurchase(g, p, action);
|
||||
return g;
|
||||
}
|
||||
if (t === 'card' && action.card === RECRUITS_CARD_ID) {
|
||||
requireRule(
|
||||
Object.keys(action).sort().join(',') === 'card,type',
|
||||
@@ -29638,6 +29765,8 @@ function applyActionInner(
|
||||
'Use only the original worm protection event with Use or Pass.');
|
||||
requireRule(decision.kind !== 'nexusEmperorPayment',
|
||||
'Use only the original allied-purchase event with Use or Pass.');
|
||||
requireRule(decision.kind !== 'nexusEmperorSellerPurchase',
|
||||
'Use only the original seller-lot event with Pay or Use.');
|
||||
if (decision.kind === 'nexusIxianReplacement') {
|
||||
requireRule(false, 'Use only the current purchased-card event with Use or Pass.');
|
||||
}
|
||||
@@ -33075,6 +33204,7 @@ export function viewGame(state: Game, id: string) {
|
||||
state = migratePrintedKullCounter(state);
|
||||
richeseBetrayalIntegrity(state);
|
||||
nexusEmperorPaymentIntegrity(state);
|
||||
nexusEmperorSellerIntegrity(state);
|
||||
kullIntegrity(state);
|
||||
advancedPreviewIntegrity(state);
|
||||
harkonnenExchangeIntegrity(state);
|
||||
@@ -33518,6 +33648,7 @@ export function viewGame(state: Game, id: string) {
|
||||
nexusEmperorSecretAlly: nexusEmperorSecretAllyOffer(g,id),
|
||||
nexusEmperorBetrayal: nexusEmperorBetrayalOffer(g, id),
|
||||
nexusEmperorPayment: nexusEmperorPaymentOffer(g, id),
|
||||
nexusEmperorSellerPurchase: nexusEmperorSellerOffer(g, id),
|
||||
nexusBgBetrayal: nexusBgBetrayalOffer(g, id),
|
||||
nexusBorrowedVoice: nexusBorrowedVoiceOffer(g, id),
|
||||
nexusFremenRevival: fremenNexusRevivalOffer(g,id),
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
import { FACTIONS, type FactionId } from './catalog';
|
||||
import { handLimit, type Game } from './engine';
|
||||
import { emperorNexusEvent } from './nexus-emperor-secret-ally';
|
||||
import { nexusCardMode, validateNexusCards } from './nexus-cards';
|
||||
import {
|
||||
pairedNexusModulesProfile, singleE1E2NexusProfile, mixedE1E2NexusProfile, mixedE3NexusProfile,
|
||||
} from './nexus-module-profile';
|
||||
import {
|
||||
pairedNexusLeaderSkillsProfile, singleE1E2NexusLeaderSkillsProfile,
|
||||
mixedE1E2NexusLeaderSkillsProfile, mixedE3NexusLeaderSkillsProfile,
|
||||
} from './leader-skill-profile';
|
||||
import type { RicheseAuction } from './richese-auction';
|
||||
import { quoteRicheseSettlement, type RicheseSettlementQuote } from './richese-settlement';
|
||||
import { quoteRicheseBetrayalInvoice } from './nexus-richese-betrayal';
|
||||
|
||||
export type EmperorNexusPurchase = {
|
||||
export type EmperorNexusBankPurchase = {
|
||||
kind: 'purchase';
|
||||
event: string;
|
||||
owner: string;
|
||||
@@ -18,6 +30,29 @@ export type EmperorNexusPurchase = {
|
||||
signature: string;
|
||||
};
|
||||
|
||||
export type EmperorNexusSellerPurchase = Omit<EmperorNexusBankPurchase, 'auctionIndex'> & {
|
||||
version: 2;
|
||||
origin: 'cache' | 'blackMarket';
|
||||
seller: string;
|
||||
round: string;
|
||||
lot: string;
|
||||
};
|
||||
export type EmperorNexusPurchase = EmperorNexusBankPurchase | EmperorNexusSellerPurchase;
|
||||
export type EmperorNexusSellerPurchaseInput = {
|
||||
round: string;
|
||||
lot: RicheseAuction;
|
||||
quote: Extract<RicheseSettlementQuote, { kind: 'sold' }>;
|
||||
bank: boolean;
|
||||
};
|
||||
|
||||
/** Original public source identity, never the cache/Black Market card face. */
|
||||
export function emperorNexusSellerPurchaseEvent(
|
||||
turn: number, owner: string, sequence: number, round: string, lot: string,
|
||||
source: 'cache' | 'blackMarket', price: number,
|
||||
): string {
|
||||
return JSON.stringify(['nexusEmperorSellerPurchase', turn, owner, sequence, round, lot, source, price]);
|
||||
}
|
||||
|
||||
/** Bind an offer to its public auction position and final bid, never its hidden card face. */
|
||||
export function emperorNexusPurchaseEvent(
|
||||
turn: number, owner: string, sequence: number, auctionIndex: number, price: number,
|
||||
@@ -52,6 +87,13 @@ export function quoteEmperorNexusPurchase(
|
||||
|
||||
/** Consistency signature, not cryptographic authentication; field order is fixed. */
|
||||
export function signEmperorNexusPurchase(record: EmperorNexusPurchase): string {
|
||||
if ('version' in record)
|
||||
return JSON.stringify([
|
||||
'emperorNexusSellerPurchase', record.version, record.kind, record.event,
|
||||
record.owner, record.turn, record.phase, record.sequence, record.faction,
|
||||
record.advanced, record.origin, record.seller, record.round, record.lot,
|
||||
record.price, record.card, record.beforeSpice, record.afterSpice,
|
||||
]);
|
||||
return JSON.stringify([
|
||||
'emperorNexusPurchase',
|
||||
record.kind,
|
||||
@@ -106,9 +148,38 @@ export function validateEmperorNexusPurchase(
|
||||
context.players.length &&
|
||||
new Set(context.players.map((player) => player.faction)).size ===
|
||||
context.players.length;
|
||||
if (plain(record) && 'version' in record) {
|
||||
if (
|
||||
!validContext ||
|
||||
Object.keys(record).sort().join(',') !==
|
||||
'advanced,afterSpice,beforeSpice,card,event,faction,kind,lot,origin,owner,phase,price,round,seller,sequence,signature,turn,version' ||
|
||||
record.version !== 2 || record.kind !== 'purchase' || record.phase !== 3 ||
|
||||
!whole(record.turn) || record.turn < 1 || record.turn > context.turn ||
|
||||
!whole(record.sequence) || !text(record.owner) || !text(record.card) ||
|
||||
!text(record.round) || !text(record.lot) || !text(record.seller) ||
|
||||
!['cache', 'blackMarket'].includes(record.origin) ||
|
||||
context.players.some(player => player.faction === 'emperor') ||
|
||||
context.players.find(player => player.id === record.seller)?.faction !== 'richese' ||
|
||||
context.players.find(player => player.id === record.owner)?.faction !== record.faction ||
|
||||
(record.origin === 'blackMarket' && (!record.advanced || record.seller === record.owner)) ||
|
||||
record.advanced !== context.advanced ||
|
||||
!whole(record.price) || record.price < 1 ||
|
||||
!whole(record.beforeSpice) || record.beforeSpice < record.price ||
|
||||
!whole(record.afterSpice) || record.afterSpice !== record.beforeSpice ||
|
||||
record.event !== emperorNexusSellerPurchaseEvent(
|
||||
record.turn, record.owner, record.sequence, record.round, record.lot, record.origin, record.price,
|
||||
) ||
|
||||
record.signature !== signEmperorNexusPurchase(record)
|
||||
)
|
||||
throw new EmperorNexusPurchaseError(
|
||||
'Emperor Nexus seller purchase has lost its original source, buyer, price or retained spice.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!validContext ||
|
||||
!plain(record) ||
|
||||
!('auctionIndex' in record) ||
|
||||
Object.keys(record).sort().join(',') !==
|
||||
'advanced,afterSpice,auctionIndex,beforeSpice,card,event,faction,kind,owner,phase,price,sequence,signature,turn' ||
|
||||
record.kind !== 'purchase' ||
|
||||
@@ -137,3 +208,142 @@ export function validateEmperorNexusPurchase(
|
||||
'Emperor Nexus purchase has lost its original bidder, paid price or full reimbursement.',
|
||||
);
|
||||
}
|
||||
|
||||
/** Reuse native Nexus profiles; the existing Richese Betrayal preview composes
|
||||
* only on its original bare paired CHOAM/Richese roster, not arbitrary overlays. */
|
||||
export function emperorNexusSellerPurchaseModeSupported(g: Game): boolean {
|
||||
if (!g.nexusCards || g.players.some(p => p.faction === 'emperor') ||
|
||||
!g.players.some(p => p.faction === 'richese') || g.ecazTreachery || g.sandtrout)
|
||||
return false;
|
||||
if (pairedNexusModulesProfile(g) || singleE1E2NexusProfile(g) ||
|
||||
mixedE1E2NexusProfile(g) || mixedE3NexusProfile(g) ||
|
||||
(!!g.leaderSkills && (pairedNexusLeaderSkillsProfile(g) ||
|
||||
singleE1E2NexusLeaderSkillsProfile(g) || mixedE1E2NexusLeaderSkillsProfile(g) ||
|
||||
mixedE3NexusLeaderSkillsProfile(g)))) return true;
|
||||
return g.expansions.length === 1 && g.expansions[0] === 'choam' &&
|
||||
g.players.length >= 2 && g.players.length <= 6 &&
|
||||
new Set(g.players.map(p => p.faction)).size === g.players.length &&
|
||||
g.players.every(p => FACTIONS.some(f => f.id === p.faction &&
|
||||
(f.expansion === 'base' || f.expansion === 'choam'))) &&
|
||||
(!g.richeseBetrayalPreview || g.players.some(p => p.faction === 'choam')) &&
|
||||
!g.homeworlds && !g.leaderSkills && !g.discoveryEnabled && !g.discoveries &&
|
||||
!g.discoveryStash && !g.greatMaker && !g.techTokens && !g.strongholdCards &&
|
||||
!g.semutaPreview && !g.advancedPreview && !g.kullPreview && !g.nexusKullPreview &&
|
||||
!g.guildBetrayalPreview && !g.nexusIxianReplacementPreview &&
|
||||
!g.nexusIxianBetrayalPreview && !g.nexusHarkonnenBetrayalPreview;
|
||||
}
|
||||
|
||||
/** Pre-acquisition proof only. The caller owns timing, overlays and mutation;
|
||||
* recompute native funding/capacity and the exact original physical source. */
|
||||
export function quoteEmperorNexusSellerPurchase(
|
||||
g: Game, input: EmperorNexusSellerPurchaseInput, owner: string,
|
||||
): { event: string; price: number; seller: string; source: 'cache' | 'blackMarket'; blocked: string | null } {
|
||||
try {
|
||||
if (!plain(input) || Object.keys(input).sort().join(',') !== 'bank,lot,quote,round' ||
|
||||
!text(input.round) || typeof input.bank !== 'boolean' ||
|
||||
!plain(input.lot) || !plain(input.quote) ||
|
||||
Object.keys(input.lot).sort().join(',') !==
|
||||
'acted,active,bid,bidder,cardId,eligible,event,method,order,outcome,owner,passed,sealed,source,tieOrder' ||
|
||||
Object.keys(input.quote).sort().join(',') !== 'allyPayment,amount,card,kind,ownPayment,winner' ||
|
||||
input.quote.kind !== 'sold' ||
|
||||
g.status !== 'playing' || g.phase !== 3 || !whole(g.turn) || g.turn < 1 ||
|
||||
(input.lot.source === 'blackMarket' && !g.advanced) ||
|
||||
!g.richeseBidding || g.richeseBidding.turn !== g.turn ||
|
||||
g.richeseBidding.stage !== 'lot' || g.richeseBidding.event !== input.round ||
|
||||
g.richeseBidding.owner !== input.lot.owner ||
|
||||
(input.lot.source === 'cache'
|
||||
? !['first', 'last'].includes(String(g.richeseBidding.position))
|
||||
: g.richeseBidding.position !== null) ||
|
||||
!g.richeseAuction || JSON.stringify(input.lot) !== JSON.stringify(g.richeseAuction) ||
|
||||
g.auction || g.currentAuctionSale || input.quote.winner !== owner ||
|
||||
input.lot.active !== null || input.lot.bidder !== owner ||
|
||||
input.lot.bid !== input.quote.amount ||
|
||||
(input.bank && (owner === input.lot.owner || input.quote.amount < 1)))
|
||||
throw new EmperorNexusPurchaseError('Emperor Nexus purchase needs its unchanged original terminal Richese lot.');
|
||||
const buyer = g.players.find(p => p.id === owner),
|
||||
seller = g.players.find(p => p.id === input.lot.owner);
|
||||
if (!buyer || seller?.faction !== 'richese')
|
||||
throw new EmperorNexusPurchaseError('Emperor Nexus purchase lost its original buyer or Richese seller.');
|
||||
const funding = g.richeseFunding?.[owner];
|
||||
if (!plain(funding) || Object.keys(funding).sort().join(',') !== 'allyPayment,amount,donor' ||
|
||||
funding.donor !== (input.quote.allyPayment ? buyer.ally : null))
|
||||
throw new EmperorNexusPurchaseError('Emperor Nexus purchase lost its original funding commitment or donor.');
|
||||
const sourceCards = input.lot.source === 'cache' ? g.richeseCache ?? [] : seller.hand;
|
||||
const physicalCards = [
|
||||
...g.players.flatMap(p => p.hand), ...g.deck, ...g.discard,
|
||||
...(g.richeseCache ?? []), ...(g.richeseRemoved ?? []),
|
||||
...(g.ixSetupCards ?? []), ...(g.ixAuction?.cards ?? []),
|
||||
...(g.ornithopter ? [g.ornithopter.card] : []),
|
||||
];
|
||||
// This shared native source proof checks exact card shape/face, unique
|
||||
// custody, cache identity and the Black Market self-purchase boundary.
|
||||
quoteRicheseBetrayalInvoice({
|
||||
players: g.players, lot: input.lot, quote: input.quote,
|
||||
donor: input.quote.allyPayment ? buyer.ally : null,
|
||||
originalRecipient: owner === seller.id
|
||||
? g.players.find(p => p.faction === 'emperor')?.id ?? null : seller.id,
|
||||
sourceCards, physicalCards,
|
||||
});
|
||||
const native = quoteRicheseSettlement({
|
||||
lot: input.lot,
|
||||
owner: { id: seller.id, handCount: seller.hand.length, handLimit: handLimit(seller) },
|
||||
winner: { id: buyer.id, handCount: buyer.hand.length, handLimit: handLimit(buyer), spice: buyer.spice },
|
||||
card: sourceCards.find(c => c.id === input.lot.cardId),
|
||||
funding: g.richeseFunding?.[owner],
|
||||
allyCredit: buyer.ally && g.aid[buyer.ally]?.recipient === owner ? g.aid[buyer.ally] : null,
|
||||
});
|
||||
if (native.kind !== 'sold' || native.amount !== input.quote.amount ||
|
||||
native.winner !== input.quote.winner || native.ownPayment !== input.quote.ownPayment ||
|
||||
native.allyPayment !== input.quote.allyPayment)
|
||||
throw new EmperorNexusPurchaseError('Emperor Nexus purchase lost its exact original nominal price or funding split.');
|
||||
let blocked: string | null = null;
|
||||
if (!emperorNexusSellerPurchaseModeSupported(g))
|
||||
blocked = 'Emperor Nexus seller purchase requires a supported original Richese Nexus profile with Emperor absent.';
|
||||
else if (buyer.ally) blocked = 'Emperor Nexus Secret Ally requires an unallied buyer.';
|
||||
else if (!g.nexusCards?.cards) blocked = 'Hold the real Emperor Nexus card to retain your purchase price.';
|
||||
else {
|
||||
validateNexusCards(g.nexusCards.cards, g.players);
|
||||
if (g.nexusCards.cards.hands[owner] !== 'emperor' ||
|
||||
nexusCardMode('emperor', buyer.faction, g.players.map(p => p.faction)) !== 'secretAlly')
|
||||
blocked = 'Hold the real Emperor Nexus Secret Ally card to retain your purchase price.';
|
||||
else {
|
||||
try { quoteEmperorNexusPurchase(native.amount, buyer.spice, native.allyPayment); }
|
||||
catch (error) {
|
||||
if (!(error instanceof EmperorNexusPurchaseError)) throw error;
|
||||
blocked = error.message;
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
event: emperorNexusSellerPurchaseEvent(g.turn, owner, g.nexusEmperorPurchaseHistory?.length ?? 0,
|
||||
input.round, input.lot.event, input.lot.source, native.amount),
|
||||
price: native.amount, seller: seller.id, source: input.lot.source, blocked,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof EmperorNexusPurchaseError) throw error;
|
||||
throw new EmperorNexusPurchaseError(error instanceof Error ? error.message : 'Malformed original Richese purchase.');
|
||||
}
|
||||
}
|
||||
|
||||
/** Prepare consumed-source evidence before payment/card movement. It records
|
||||
* retained spice, not an actual paid invoice or a fabricated seller subsidy. */
|
||||
export function createEmperorNexusSellerPurchase(
|
||||
g: Game, input: EmperorNexusSellerPurchaseInput,
|
||||
): EmperorNexusSellerPurchase {
|
||||
if (!plain(input) || !plain(input.quote) || !text(input.quote.winner))
|
||||
throw new EmperorNexusPurchaseError('Emperor Nexus purchase needs its original sold source and buyer.');
|
||||
const quote = quoteEmperorNexusSellerPurchase(g, input, input.quote.winner);
|
||||
if (quote.blocked) throw new EmperorNexusPurchaseError(quote.blocked);
|
||||
const buyer = g.players.find(p => p.id === input.quote.winner)!;
|
||||
const receipt: EmperorNexusSellerPurchase = {
|
||||
version: 2, kind: 'purchase', event: quote.event, owner: buyer.id, turn: g.turn,
|
||||
phase: 3, sequence: g.nexusEmperorPurchaseHistory?.length ?? 0,
|
||||
faction: buyer.faction, advanced: g.advanced, origin: quote.source,
|
||||
seller: quote.seller, round: input.round, lot: input.lot.event,
|
||||
price: quote.price, card: input.quote.card.id,
|
||||
beforeSpice: buyer.spice, afterSpice: buyer.spice, signature: '',
|
||||
};
|
||||
receipt.signature = signEmperorNexusPurchase(receipt);
|
||||
validateEmperorNexusPurchase(g, receipt);
|
||||
return receipt;
|
||||
}
|
||||
+17
-15
@@ -4342,25 +4342,26 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
},
|
||||
{
|
||||
id: 'nexus-emperor-secret-ally',
|
||||
title: 'Emperor Nexus Secret Ally: extra revivals and bank-auction purchase',
|
||||
title: 'Emperor Nexus Secret Ally: extra revivals and original purchases',
|
||||
category: 'Advanced & expansions',
|
||||
coverage: 'Partial',
|
||||
developmentStage: 'Prototyped',
|
||||
summary: 'When Emperor is absent, an unallied holder can spend its Nexus card on exactly three additional free force returns during Revival or on an ordinary bank-auction purchase after proving the full winning bid from personal spice. Seller-paid, allied, non-Bidding and combined-module acceptance remain unfinished.',
|
||||
summary: 'With Emperor absent, an unallied holder can choose exactly three additional free returns or retain a personally affordable original purchase price. Classic bank auctions and original Richese cache/Advanced Black Market now have distinct connected paths. Allied funding, direct CHOAM/Ambassador purchases and complete combinations remain unfinished.',
|
||||
steps: [
|
||||
'Use the private Emperor Secret Ally controls during Revival. Select the physical elite count when more than one legal group is available. The card returns exactly three eligible forces from Tanks to reserves and is discarded once.',
|
||||
'These returns cost no spice and do not consume ordinary force-revival or free-revival allowances. You may use the card before or after ordinary revivals, while Revival remains open.',
|
||||
'The Advanced one-per-turn Fedaykin limit still applies across ordinary revivals and this card. The extra total allowance does not permit another elite revival. Fewer than three eligible counters is an unresolved case and remains unavailable.',
|
||||
'The revival prototype supports Basic and Advanced base factions with Nexus alone. Other expansion, Homeworld, suppression and combined-module effects retain their separate boundaries. Keeping the card during Revival creates no public decision or extra confirmation.',
|
||||
'At an eligible normal bank-auction winner payment, prove the positive final bid entirely from your own spice, then spend Emperor Nexus to receive that physical Treachery Card without losing spice. Every winner sees the same payment confirmation even if they hold no Nexus card; this privacy-preserving timing remains a provisional product choice pending the existing user question. Ordinary spice or legal Karama payment is still available.',
|
||||
'The independent revival grant enters original pending revival. Under the visible provisional policy, native Tleilaxu prevention/income, typed reserves, Tech and eligible Homeworld handling remain; ordinary free-rate blocking does not reprice it. Keeping the card creates no extra public confirmation.',
|
||||
'At an eligible classic 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 recommendation is selected under the decision priority. Ordinary spice or legal Karama remains available.',
|
||||
'Original Richese cache and Advanced Black Market offer Pay or eligible Emperor Nexus Use after original Richese Betrayal. The provisional seller-unpaid policy retains your full personally affordable price without bank reimbursement. One original lot, native Harkonnen bonus and source successor continue; legal self-cache is included but self-Black remains prohibited. Original self-purchase forced discard cannot consume this Nexus or acquire a card.',
|
||||
],
|
||||
related: ['nexus-cards', 'revival'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Fixed three-force return preserves ordinary allowances and physical elite limits. The separate bank-auction path retains the personally funded bid, transfers one physical lot and spends the Nexus with signed saved receipts. Seller/allied, other-purchase and combined producers remain unfinished.', evidence: ['game/nexus-emperor-secret-ally.ts', 'game/nexus-emperor-purchase.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Private revival/auction options, legal elite selection, bid proof and unavailable reasons with readable physical card reference; normal payment remains offered.', evidence: ['components/nexus-emperor-secret-ally.tsx', 'components/nexus-cards.tsx', 'components/game-table.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'All four profiles use projected legal revival and personally funded bank-purchase choices without rival hand/spice inspection. Complete Nexus strategy remains uncalibrated.', evidence: ['game/nexus-emperor-secret-ally-options.ts', 'game/bots.ts'] },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Printed alternatives, revival allowances, bid proof, physical purchase and provisional uniform payment timing retain explicit unresolved boundaries.', evidence: ['docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Focused Basic/Advanced rules, controls, bots, JSON, browser owner/rival projection and SQLite restart/CAS cover stale cross-lot offers, hand/custody rejection and duplicate requests; broader module acceptance remains open.', evidence: ['tests/nexus-emperor-secret-ally.test.ts', 'tests/nexus-emperor-secret-ally-recovery.test.ts', 'tests/nexus-emperor-purchase-engine.test.ts', 'tests/nexus-emperor-purchase-recovery.test.ts', 'tests/nexus-emperor-secret-ally-purchase-controls.test.ts'] },
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Independent extra-three enters original pending revival. Distinct classic bank and original Richese seller paths retain personally affordable price and one actual lot with signed source receipts. Original seller/stock/bonus/successor run with no invented reimbursement. Allied and other acquisition producers remain open.', evidence: ['game/nexus-emperor-secret-ally.ts', 'game/nexus-emperor-purchase.ts', 'game/auction-continuation-quote.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Private typed revival and public uniform buyer confirmations with eligible Use, retained-price proof and visible provisional seller-unpaid policy; original Pay and native reactions remain.', evidence: ['components/nexus-emperor-secret-ally.tsx', 'components/nexus-emperor-seller-purchase.tsx', 'components/nexus-cards.tsx', 'components/game-table.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Minimal profiles use projected legal revival and original personally funded purchase choices, or Pay, without inspecting rival private custody. Complete strategy/calibration remain unfinished.', evidence: ['game/nexus-emperor-secret-ally-options.ts', 'game/bots.ts'] },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Printed alternatives, independent revival and original bank/Richese purchases distinguish provisional native prevention, uniform timing and seller-unpaid recommendation from publisher rulings. Other acquisition sources and complete combinations remain open.', evidence: ['docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md', 'docs/RULE_DECISIONS.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Current seller10-file union108/108, types/lint/build and twenty original API profiles pass. Actual390px cache/Black/self-cache/diverted-cache Use proves unchanged wallets, one lot and native bonus/successor without overflow/errors. Prior bank/revival evidence remains; broader acceptance and assurance remain open.', evidence: ['tests/nexus-emperor-secret-ally.test.ts', 'tests/nexus-emperor-secret-ally-recovery.test.ts', 'tests/nexus-emperor-purchase-engine.test.ts', 'tests/nexus-emperor-purchase-recovery.test.ts', 'tests/nexus-emperor-secret-ally-purchase-controls.test.ts', 'tests/fixture-nexus-emperor-seller.ts', 'tests/nexus-emperor-seller-purchase.test.ts', 'tests/nexus-emperor-seller-runtime.test.ts'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -4568,8 +4569,9 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'With BG absent, every fresh Nexus battle asks both combatants in attacker order before Prescience, Nexus inspection or sealed plans. Continue preserves the card. The actual unallied BG-card holder may spend it to require or forbid one canonical type against the opponent. Under the explicit provisional recommendation, original Karama can cancel this named Voice advantage; the Nexus stays spent, only Voice is removed and native preparation continues. Existing in-flight preparation is not retrofitted. Native compliance and special/mode restrictions remain; this does not complete other unfinished effects.',
|
||||
'Bene Gesserit Betrayal can prevent the native Voice while the ordinary pre-plan response is open. In Nexus games every seat responds even without a canceling card, so the window does not reveal the hidden holder. An unallied opponent spends one physical Bene Gesserit Nexus card to remove only that declared Voice; Prescience and plan commitments continue. Truthtrance has priority. This path does not grant borrowed Voice.',
|
||||
'Emperor Cunning requires an Advanced battle with at least five ordinary Emperor counters and no actual Sardaukar, before submitting your plan. The played Nexus card opens one Karama response. Allowing it makes five ordinary counters count as Sardaukar for this battle; cancellation prevents the entire enhancement and leaves the Nexus card spent.',
|
||||
'Temporary Sardaukar use the applicable strength and support rules, including the Fremen exception, but remain physically ordinary counters. Their losses go to Tanks as ordinary counters, without changing starred reserves or Tanks. Existing inspected or promised plans remain binding. The role ends after this battle; fewer-than-five groups remain unavailable. Secret Ally revival and normal bank-auction purchase have connected controls; normal forced allied funding is the separate operation below. Seller-paid Secret Ally purchases remain unfinished.',
|
||||
'Temporary Sardaukar use the applicable strength and support rules, including the Fremen exception, but remain physically ordinary counters. Their losses go to Tanks as ordinary counters, without changing starred reserves or Tanks. Existing inspected or promised plans remain binding. The role ends after this battle; fewer-than-five groups remain unavailable. Secret Ally independent revival, classic bank and original Richese seller purchases have connected controls; compulsory allied funding remains the separate operation below. Other acquisition sources and full combinations remain open.',
|
||||
'Emperor Secret Ally revival selects exactly three real normal/starred counters for free, outside ordinary total/free and native allied-extra usage. Declaration spends one Nexus and enters original pending revival; no leader or absent native payer is granted. Under the visible provisional independent-source policy, native Tleilaxu may prevent it and actual free returns award income once; ordinary free-rate blocking and Recruits do not reprice or double this fixed grant. Denial keeps both spent cards without forces/income/Tech or refund. Native typed caps, reserve deposits, Axlotl and eligible newly returned Fedaykin placement continue. Tleilax ordinary Free Revival deployment is not extended to this extra source. Source receipts distinguish requested, returned and denied; old physical receipts never replay.',
|
||||
'Original Richese cache and Advanced Black Market sold lots offer every buyer Pay after original Richese Betrayal. An eligible unallied Emperor Nexus holder, with Emperor absent and full price personally affordable, may Use to keep that spice and leave seller/bank unpaid under the visible provisional recommendation. Actual source stock, hand limits, native Harkonnen bonus and successor continue once. Legal self-cache is included; self-Black remains prohibited. Original self-discard produces no acquisition or Emperor Nexus spend. Direct CHOAM/Ambassador purchases remain open.',
|
||||
'Emperor Betrayal has a battle alternative in Advanced play: an unallied opponent holding the Emperor Nexus card can spend it during the ordinary pre-plan Sardaukar response to suppress actual Sardaukar strength for this battle. Every seat receives the same response opportunity even with no card, keeping hidden custody private. The starred pieces remain physical Sardaukar; no card grants a new force or cancels their separate support benefit.',
|
||||
'During an original positive normal-auction purchase by Emperor’s ally, possible unallied held-Nexus rivals acknowledge in storm order before winner payment, regardless of secret face. Only the current actual Emperor-card holder may Use; Pass keeps cards and original payment. Under the provisional conservative full-price interpretation, native Emperor covers the full original price, reusing prepaid aid and adding only missing own spice. The buyer keeps their own spice and acquires one original card. Native Emperor income remains separately cancelable without refund; original next-lot inspection continues. No excess gift, bank-created subsidy or future-income borrowing is granted. Underfunded/fully prepaid Use is unavailable; special seller lots and full combinations remain open.',
|
||||
'With Fremen absent, an actual unallied Fremen-card holder may enable a fixed ordinary free quota of three at a quiet Revival boundary, then use normal partial/typed revival controls. Activation moves no counters, leader or spice. Actual prior free use counts; paid returns keep their cost and total usage. Native total limits, higher free rates, typed caps, prevention, Homeworld deposits/deployment, Axlotl activity and once-only Tleilaxu income remain original. Recruits doubles its native rate, not this fixed quota. Useless or unknown-ledger grants are unavailable. This is the visibly provisional recommended interpretation; strict old physical receipts adopt metadata without replay. Independent sandworm protection remains the separate operation below.',
|
||||
@@ -4599,11 +4601,11 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
],
|
||||
related: ['ecaz-modules', 'spice-blow', 'alliance-funding', 'choam-worthless', 'choam-kull', 'nexus-choam-kull', 'nexus-richese-betrayal', 'nexus-guild-betrayal', 'nexus-ixian-replacement', 'nexus-ixian-betrayal'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Existing effects and compulsory funding remain. Emperor Secret Ally now declares a signed exact-three independent source into original pending revival, with requested/returned/denied physical receipts and no ordinary or native allied-extra usage. Native Tleilaxu prevention/income, typed reserves, once-only Tech and Homeworld handling continue. Cyborg caps and returned Fedaykin placement stay native; zero-cost extras are not Tleilax ordinary Free Revival. Legacy physical evidence never replays. Wider combinations, custody and seller purchases remain open.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/nexus-ecaz-inquiry.ts', 'game/nexus-ecaz-duke.ts', 'game/duke-vidal.ts', 'game/nexus-emperor-secret-ally.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/nexus-module-profile.ts', 'game/engine.ts', 'game/nexus-tleilaxu-revival.ts', 'game/nexus-borrowed-voice.ts', 'game/combat-response-quote.ts', 'game/karama-context.ts', 'game/nexus-fremen-protection.ts', 'game/admin-seat-ai-shape.ts', 'game/richese-cancellation.ts', 'game/bot-mobility.ts', 'game/planetologist-movement.ts', 'game/revival.ts', 'game/nexus-fremen-revival-options.ts','game/nexus-emperor-payment.ts', 'game/nexus-emperor-secret-ally.ts', 'game/revival-resume.ts', 'game/revival-cancellation.ts', 'game/homeworld-revival-deployment.ts', 'game/homeworld-revival-return.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Extra-three retains its actual typed selector and labels the provisional independent-source policy. Original native Tleilaxu clearance shows the committed extra source and no-refund consequence. Actual390px Allow then ordinary3, native Prevent and one returned Fedaykin placement use production controls; receipt lifecycle follows actual settlement. Existing compulsory funding and other Nexus controls remain. Wider controls and complete-mode acceptance stay open.', evidence: ['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/nexus-ecaz-duke.tsx', 'components/nexus-richese-betrayal.tsx', 'components/game-table.tsx', 'components/nexus-tleilaxu-revival.tsx', 'components/battle-preparation.tsx', 'components/nexus-fremen-revival.tsx','components/nexus-emperor-payment.tsx', 'components/nexus-emperor-secret-ally.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Existing minimal profiles retain shared legal grant actions and native responses. Extra-three now enters the original pending pipeline instead of directly moving forces, with actual current typed quote/caps. Genuine Easy classic activation commits the independent physical return; compulsory funding and Fremen ordinary quota remain legal. No strategic tuning or four-tier calibration is claimed; both remain deferred and unfinished.', evidence: ['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/nexus-ecaz-duke-options.ts', 'game/nexus-richese-options.ts', 'game/bot-nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts', 'game/nexus-fremen-revival-options.ts','game/nexus-emperor-payment.ts', 'game/nexus-emperor-secret-ally-options.ts'] },
|
||||
{area:'Documentation',status:'Partial',detail:'Common rules and twelve faces retain remaining custody, inquiry, nested Cunning and combinations. Contracts distinguish named borrowed Voice cancellation, independent worm protection, fresh/storm BG card exception, Fremen ordinary fixed quota and Emperor original full-price funding. Interpretations are visibly provisional, not publisher answers or separately answered numerical rulings. Special seller composition and full-mode gates remain.',evidence:['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_FREMEN_RULES.md', 'docs/NEXUS_ECAZ_RULES.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_RICHESE_RULES.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md', 'docs/NEXUS_GUILD_RULES.md']},
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Extra-return affected16-file union266/266 and types/lint/build pass. Genuine classic/native-Tleilaxu/Ix/Fedaykin setup, casualties, worm/pact/deal and Revival exercise original Allow/Prevent/income, later ordinary returns, typed caps and actual Homeworld placement. Fourteen API profiles reach Movement, including native deposits and Basic/Advanced Fedaykin. Three real390px surfaces prove requested-before-return, extra3 then ordinary3 with unchanged separate ledgers, once-only income/Tech, denied no-return/no-refund, and one star to Tabr14. Original legal reinforcement geometry and Fremen zero-support plans are respected; fixture-only wrong assumptions/obsolete pins are removed. No overflow/errors. Prior funding155/155/quota329/329 evidence remains; wider combinations, assurance and deployed acceptance stay open.', evidence: ['tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', 'tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-controls.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts', 'tests/classic-nexus-skills-payments-runtime.test.ts', 'tests/classic-nexus-skills-battles-runtime.test.ts', 'tests/nexus-skills-modules-payments-runtime.test.ts', 'tests/nexus-skills-modules-battles-runtime.test.ts', 'tests/paired-ix-nexus-skills-modules-runtime.test.ts', 'tests/paired-choam-nexus-skills-modules-runtime.test.ts', 'tests/ecaz-nexus-skills-modules-runtime.test.ts', 'tests/moritani-nexus-skills-modules-runtime.test.ts', 'docs/NEXUS_CARD_RULES.md', 'tests/nexus-tleilaxu-revival.test.ts', 'tests/fixture-nexus-tleilaxu-secret-ally.ts', 'tests/nexus-tleilaxu-secret-ally-runtime.test.ts', 'tests/nexus-borrowed-voice.test.ts', 'tests/fixture-nexus-borrowed-voice.ts', 'tests/nexus-borrowed-voice-runtime.test.ts', 'tests/nexus-fremen-protection.test.ts', 'tests/fixture-nexus-fremen-protection.ts', 'tests/nexus-fremen-protection-runtime.test.ts', 'tests/fixture-stronghold-factions.ts', 'tests/fixture-nexus-advisor-fresh.ts', 'tests/nexus-advisor-fresh-runtime.test.ts', 'tests/fixture-nexus-advisor-storm.ts', 'tests/nexus-advisor-storm-runtime.test.ts', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'tests/nexus-fremen-free-quota.test.ts', 'tests/fixture-nexus-fremen-free-quota.ts', 'tests/nexus-fremen-free-quota-runtime.test.ts', 'tests/nexus-fremen-revival.test.ts','tests/nexus-emperor-payment.test.ts','tests/fixture-nexus-emperor-payment.ts','tests/nexus-emperor-payment-runtime.test.ts','docs/NEXUS_EMPEROR_RULES.md', 'tests/nexus-emperor-extra-source.test.ts', 'tests/nexus-emperor-extra-continuation.test.ts', 'tests/fixture-nexus-emperor-extra.ts', 'tests/nexus-emperor-extra-runtime.test.ts', 'tests/fixture-single-nexus-e1.ts', 'docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md'] },
|
||||
{area:'Implementation',status:'Partial',detail:"Existing extra revival and compulsory funding remain. Emperor Secret Ally now retains personally affordable original Richese cache/Advanced Black Market price after original Betrayal, with strict signed source receipts. Seller/bank receive no invented reimbursement; actual stock, hand limits, Hark bonus and successor continue. Basic/Advanced cache and legal self-cache are included; self-Black stays blocked. Direct CHOAM/Ambassador purchases, wider combinations and assurance remain open.",evidence:['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/nexus-ecaz-inquiry.ts', 'game/nexus-ecaz-duke.ts', 'game/duke-vidal.ts', 'game/nexus-emperor-secret-ally.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/nexus-module-profile.ts', 'game/engine.ts', 'game/nexus-tleilaxu-revival.ts', 'game/nexus-borrowed-voice.ts', 'game/combat-response-quote.ts', 'game/karama-context.ts', 'game/nexus-fremen-protection.ts', 'game/admin-seat-ai-shape.ts', 'game/richese-cancellation.ts', 'game/bot-mobility.ts', 'game/planetologist-movement.ts', 'game/revival.ts', 'game/nexus-fremen-revival-options.ts','game/nexus-emperor-payment.ts', 'game/nexus-emperor-secret-ally.ts', 'game/revival-resume.ts', 'game/revival-cancellation.ts', 'game/homeworld-revival-deployment.ts', 'game/homeworld-revival-return.ts', "game/nexus-emperor-purchase.ts", "game/auction-continuation-quote.ts"]},
|
||||
{area:'Player controls',status:'Partial',detail:"Uniform winning-buyer Pay follows original Richese Betrayal regardless concealed face. Actual eligible holder sees Use with retained price and visible provisional seller-unpaid policy. Original Truthtrance discard and bonus controls continue. Actual390px cache, Black Market, self-cache and diverted cache prove usable controls without overflow/errors. Wider surfaces and complete-mode acceptance remain open.",evidence:['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/nexus-ecaz-duke.tsx', 'components/nexus-richese-betrayal.tsx', 'components/game-table.tsx', 'components/nexus-tleilaxu-revival.tsx', 'components/battle-preparation.tsx', 'components/nexus-fremen-revival.tsx','components/nexus-emperor-payment.tsx', 'components/nexus-emperor-secret-ally.tsx', "components/nexus-emperor-seller-purchase.tsx"]},
|
||||
{area:'AI',status:'Partial',detail:"Minimal profiles use the shared projected seller entitlement and original Pay otherwise, without inspecting rival private custody. Existing extra-return, compulsory funding and other Nexus adapters remain. Strategy and four-tier calibration remain deferred and unfinished.",evidence:['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/nexus-ecaz-duke-options.ts', 'game/nexus-richese-options.ts', 'game/bot-nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts', 'game/nexus-fremen-revival-options.ts','game/nexus-emperor-payment.ts', 'game/nexus-emperor-secret-ally-options.ts']},
|
||||
{area:'Documentation',status:'Partial',detail:"Printed alternatives retain explicit partial gates. Original Richese seller composition now selects visibly provisional unpaid-seller recommendation under the decision priority, not publisher clarification or a numerical user ruling. Original Betrayal precedes buyer confirmation; direct CHOAM/Ambassador sources and full combinations remain open.",evidence:['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_FREMEN_RULES.md', 'docs/NEXUS_ECAZ_RULES.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_RICHESE_RULES.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md', 'docs/NEXUS_GUILD_RULES.md', "docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md", "docs/RULE_DECISIONS.md"]},
|
||||
{area:'Verification',status:'Partial',detail:"Seller affected10-file union108/108 and types/lint/build pass. Genuine original setup/worm/pact/draw and positive cache/Black Market sales exercise Pay/Use, source stock, actual Hark bonus, self-cache, Truthtrance and original Betrayal pass/diversion/self-discard. Actual390px four surfaces prove unchanged wallets, one original card and native source successor without overflow/errors. Prior extra266/266, funding155/155 and quota329/329 evidence remains. Full combinations, assurance and deployed acceptance remain open.",evidence:['tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', 'tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-controls.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts', 'tests/classic-nexus-skills-payments-runtime.test.ts', 'tests/classic-nexus-skills-battles-runtime.test.ts', 'tests/nexus-skills-modules-payments-runtime.test.ts', 'tests/nexus-skills-modules-battles-runtime.test.ts', 'tests/paired-ix-nexus-skills-modules-runtime.test.ts', 'tests/paired-choam-nexus-skills-modules-runtime.test.ts', 'tests/ecaz-nexus-skills-modules-runtime.test.ts', 'tests/moritani-nexus-skills-modules-runtime.test.ts', 'docs/NEXUS_CARD_RULES.md', 'tests/nexus-tleilaxu-revival.test.ts', 'tests/fixture-nexus-tleilaxu-secret-ally.ts', 'tests/nexus-tleilaxu-secret-ally-runtime.test.ts', 'tests/nexus-borrowed-voice.test.ts', 'tests/fixture-nexus-borrowed-voice.ts', 'tests/nexus-borrowed-voice-runtime.test.ts', 'tests/nexus-fremen-protection.test.ts', 'tests/fixture-nexus-fremen-protection.ts', 'tests/nexus-fremen-protection-runtime.test.ts', 'tests/fixture-stronghold-factions.ts', 'tests/fixture-nexus-advisor-fresh.ts', 'tests/nexus-advisor-fresh-runtime.test.ts', 'tests/fixture-nexus-advisor-storm.ts', 'tests/nexus-advisor-storm-runtime.test.ts', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'tests/nexus-fremen-free-quota.test.ts', 'tests/fixture-nexus-fremen-free-quota.ts', 'tests/nexus-fremen-free-quota-runtime.test.ts', 'tests/nexus-fremen-revival.test.ts','tests/nexus-emperor-payment.test.ts','tests/fixture-nexus-emperor-payment.ts','tests/nexus-emperor-payment-runtime.test.ts','docs/NEXUS_EMPEROR_RULES.md', 'tests/nexus-emperor-extra-source.test.ts', 'tests/nexus-emperor-extra-continuation.test.ts', 'tests/fixture-nexus-emperor-extra.ts', 'tests/nexus-emperor-extra-runtime.test.ts', 'tests/fixture-single-nexus-e1.ts', 'docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md', "tests/fixture-nexus-emperor-seller.ts", "tests/nexus-emperor-seller-purchase.test.ts", "tests/nexus-emperor-seller-runtime.test.ts"]},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -91,6 +91,7 @@ const DECISIONS = {
|
||||
nexusFremenCunningOffer: true,
|
||||
nexusFremenProtection: true,
|
||||
nexusEmperorPayment: true,
|
||||
nexusEmperorSellerPurchase: true,
|
||||
nexusFremenCunningRide: true,
|
||||
moritaniRetention: true,
|
||||
moritaniSpecialKarama: true,
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializePairedNexusGameForAudit, initializeRicheseBetrayalGameForAudit, joinGame, newPlayer,
|
||||
type Action, type Game, type Player,
|
||||
} from '../game/engine';
|
||||
import type { Card } from '../game/cards';
|
||||
import { treacheryDeck } from '../game/cards';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { validateNexusCards } from '../game/nexus-cards';
|
||||
import { nextPairedChoamStep } from './fixture-paired-choam-nexus-modules';
|
||||
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||
import { frontNativeHomeworldSpice } from './fixture-homeworld-native-e1e2-modules';
|
||||
|
||||
export type NexusEmperorSellerStep = { actor: string; action: Action };
|
||||
export type NexusEmperorSellerTransition = { before: Game; step: NexusEmperorSellerStep; after: Game };
|
||||
export type NexusEmperorSellerFixture = {
|
||||
game: Game; event: string; winner: string; seller: string; source: 'cache' | 'blackMarket';
|
||||
card: Card; price: number; round: string; lot: string;
|
||||
wallets: Record<string, number>; originalCards: string[]; actions: NexusEmperorSellerStep[];
|
||||
chronology: {
|
||||
initial: Game; undealt: Game; afterSetup: Game; firstMentat: NexusEmperorSellerTransition;
|
||||
worm: Game; alliance: Game; draw: NexusEmperorSellerTransition; afterClosingDraw: Game;
|
||||
beforeOffer: Game; beforeWinnerBid: Game; beforeFinalBid: Game;
|
||||
};
|
||||
};
|
||||
export function nexusEmperorSellerPlayer(game: Game, id: string): Player {
|
||||
const player = game.players.find(candidate => candidate.id === id);
|
||||
assert.ok(player, `Missing original seller-lot seat ${id}`);
|
||||
return player;
|
||||
}
|
||||
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision &&
|
||||
!game.truthtrance && !game.pendingTreacheryDiscard;
|
||||
export function stepNexusEmperorSeller(game: Game, step: NexusEmperorSellerStep,
|
||||
actions?: NexusEmperorSellerStep[]): Game {
|
||||
actions?.push(structuredClone(step));
|
||||
return withClassicDiscoveryNexusLottery(() => applyAction(game, step.actor, step.action));
|
||||
}
|
||||
/** Consume only original owner-labelled choices; never manufacture a private plan. */
|
||||
export function nextNexusEmperorSellerStep(game: Game): NexusEmperorSellerStep {
|
||||
if (clean(game) && game.nexusCards?.phase?.stage === 'drawing') {
|
||||
const actor = game.nexusCards.phase.eligible.find(id => !game.nexusCards!.phase!.done.includes(id));
|
||||
assert.ok(actor);
|
||||
return { actor, action: { type: 'nexusCardChoice', turn: game.turn,
|
||||
card: game.nexusCards.cards!.hands[actor], choice: 'keep', ownRedraws: 0 } };
|
||||
}
|
||||
if (!game.phaseOpening && !game.response && game.decision?.kind === 'nexusEmperorSellerPurchase')
|
||||
return { actor: game.decision.player,
|
||||
action: { type: 'nexusEmperorSellerPurchase', event: game.decision.event, use: false } };
|
||||
const next = nextPairedChoamStep(game);
|
||||
assert.ok(next, 'The original seller chronology requires a real human battle plan');
|
||||
return next;
|
||||
}
|
||||
export function advanceNexusEmperorSeller(game: Game, until: (game: Game) => boolean,
|
||||
actions?: NexusEmperorSellerStep[]): Game {
|
||||
for (let n = 0; n < 2000; n++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
game = stepNexusEmperorSeller(game, nextNexusEmperorSellerStep(game), actions);
|
||||
}
|
||||
throw Error('The original seller chronology did not reach its requested boundary');
|
||||
}
|
||||
/** Original physical zones, excluding historical ordinary-auction aliases. */
|
||||
export function nexusEmperorSellerCards(game: Game): string[] {
|
||||
return [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
|
||||
...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
|
||||
...(game.auction?.cards.slice(game.auction.index + Number(!!game.currentAuctionSale &&
|
||||
game.currentAuctionSale.origin === 'normal')) ?? [])].map(card => card.id).sort();
|
||||
}
|
||||
export function nexusEmperorSellerInventory(game: Game): void {
|
||||
validateNexusCards(game.nexusCards!.cards!, game.players);
|
||||
assert.deepEqual(nexusEmperorSellerCards(game),
|
||||
[...treacheryDeck(['choam']), ...richeseCards()].map(card => card.id).sort());
|
||||
for (const player of game.players) {
|
||||
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, n) => sum + n, 0), 20);
|
||||
if (player.elites) assert.equal(player.elites.reserves + player.elites.tanks +
|
||||
Object.values(player.elites.forces).reduce((sum, n) => sum + n, 0), 3);
|
||||
}
|
||||
}
|
||||
|
||||
/** Native Basic/Advanced paired Richese/CHOAM with classic seats. Starting cards,
|
||||
* first turn, later worm/alliance/closing Nexus draw and winning bid are actual
|
||||
* source actions. Only undealt singleton order/scoped scalar entropy is selected;
|
||||
* hands, balances, counters, alliances, clocks and receipts are never assigned. */
|
||||
export function createNexusEmperorSellerFixture(options: {
|
||||
advanced?: boolean; source?: 'cache' | 'blackMarket'; winnerFaction?: 'harkonnen' | 'richese';
|
||||
nexusCard?: 'emperor' | 'guild'; truthtrance?: boolean;
|
||||
betrayal?: 'pass' | 'bank' | 'before';
|
||||
} = {}): NexusEmperorSellerFixture {
|
||||
const source = options.source ?? 'cache', advanced = options.advanced ?? source === 'blackMarket';
|
||||
const winner = options.winnerFaction ?? 'harkonnen', seller = 'richese', price = 2;
|
||||
assert.ok(source !== 'blackMarket' || advanced, 'Original Black Market is Advanced only');
|
||||
assert.ok(source !== 'blackMarket' || winner !== seller, 'Original Black Market excludes seller self-purchase');
|
||||
const actions: NexusEmperorSellerStep[] = [];
|
||||
let game = createGame('NEXUSEMPERORSELLER', newPlayer(seller, 'Richese', 'richese'), advanced, ['choam']);
|
||||
for (const faction of ['choam', 'harkonnen', 'fremen'] as const)
|
||||
joinGame(game, newPlayer(faction, faction, faction));
|
||||
if (options.betrayal) joinGame(game, newPlayer('guild', 'Guild', 'guild'));
|
||||
game.nexusCards = { cards: null, phase: null };
|
||||
const initial = structuredClone(game);
|
||||
for (const player of game.players)
|
||||
game = stepNexusEmperorSeller(game, { actor: player.id, action: { type: 'ready' } }, actions);
|
||||
game = withClassicDiscoveryNexusLottery(() => options.betrayal
|
||||
? initializeRicheseBetrayalGameForAudit(game) : initializePairedNexusGameForAudit(game));
|
||||
assert.equal(game.status, 'setup');
|
||||
assert.ok(game.players.every(player => player.hand.length === 0));
|
||||
const prefix: Card[] = [];
|
||||
// A real setup-dealt Karama retains native source/bonus cancellation windows;
|
||||
// the quiet driver passes them rather than fabricating a response frame.
|
||||
for (const effect of ['worthless', options.truthtrance ? 'truthtrance' : 'worthless', 'worthless', 'worthless', 'karama']) {
|
||||
const index = game.deck.findIndex(card => effect === 'worthless' ? card.kind === effect : card.effect === effect);
|
||||
assert.ok(index >= 0);
|
||||
prefix.push(game.deck.splice(index, 1)[0]);
|
||||
}
|
||||
game.deck.unshift(...prefix);
|
||||
const undealt = structuredClone(game);
|
||||
game = advanceNexusEmperorSeller(game, state => state.status === 'playing', actions);
|
||||
const afterSetup = structuredClone(game);
|
||||
assert.equal(nexusEmperorSellerPlayer(game, 'harkonnen').hand.length, 2);
|
||||
assert.equal(nexusEmperorSellerPlayer(game, seller).hand.length, 1);
|
||||
game = advanceNexusEmperorSeller(game, state => state.phase === 1 && clean(state), actions);
|
||||
frontNativeHomeworldSpice(game, card => 'territory' in card && card.territory === 'hagga_basin', 0);
|
||||
if (advanced) frontNativeHomeworldSpice(game, card => 'territory' in card && card.territory === 'broken_land', 1);
|
||||
let firstMentat: NexusEmperorSellerTransition | undefined;
|
||||
while (game.turn === 1) {
|
||||
const before = structuredClone(game), step = nextNexusEmperorSellerStep(game);
|
||||
game = stepNexusEmperorSeller(game, step, actions);
|
||||
if (game.turn === 2) firstMentat = { before, step, after: structuredClone(game) };
|
||||
}
|
||||
assert.ok(firstMentat);
|
||||
assert.equal(firstMentat.before.phase, 8);
|
||||
game = advanceNexusEmperorSeller(game, state => state.turn === 2 && state.phase === 1 && clean(state) &&
|
||||
!state.spiceSequence && !state.spiceResolution && !state.spiceWindow && !state.nexus && !state.ready.length, actions);
|
||||
frontNativeHomeworldSpice(game, card => 'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper, 0);
|
||||
frontNativeHomeworldSpice(game, card => 'territory' in card && card.territory === 'rock_outcroppings', 1);
|
||||
if (advanced) frontNativeHomeworldSpice(game, card => 'territory' in card && card.territory === 'oh_gap', 2);
|
||||
game = advanceNexusEmperorSeller(game, state => !!state.nexus && !state.spiceWindow && !state.spiceResolution && clean(state), actions);
|
||||
const worm = structuredClone(game);
|
||||
for (const [actor, target] of [['choam', 'fremen'], ['fremen', 'choam']])
|
||||
game = stepNexusEmperorSeller(game, { actor, action: { type: 'alliance', target } }, actions);
|
||||
const alliance = structuredClone(game);
|
||||
game = advanceNexusEmperorSeller(game, state => state.nexusCards?.phase?.stage === 'drawing' && clean(state), actions);
|
||||
assert.ok(game.nexusCards!.phase!.eligible.includes(winner));
|
||||
const cards = game.nexusCards!.cards!, index = cards.deck.indexOf(options.nexusCard ?? 'emperor');
|
||||
assert.ok(index >= 0);
|
||||
cards.deck.unshift(cards.deck.splice(index, 1)[0]);
|
||||
const drawBefore = structuredClone(game), drawStep: NexusEmperorSellerStep = { actor: winner,
|
||||
action: { type: 'nexusCardChoice', turn: game.turn, card: cards.hands[winner], choice: 'draw', ownRedraws: 0 } };
|
||||
game = stepNexusEmperorSeller(game, drawStep, actions);
|
||||
const draw = { before: drawBefore, step: drawStep, after: structuredClone(game) };
|
||||
if (options.betrayal) {
|
||||
const remaining = game.nexusCards!.cards!, richese = remaining.deck.indexOf('richese');
|
||||
assert.ok(richese >= 0);
|
||||
remaining.deck.unshift(remaining.deck.splice(richese, 1)[0]);
|
||||
game = stepNexusEmperorSeller(game, { actor: 'guild', action: { type: 'nexusCardChoice',
|
||||
turn: game.turn, card: remaining.hands.guild, choice: 'draw', ownRedraws: 0 } }, actions);
|
||||
}
|
||||
game = advanceNexusEmperorSeller(game, state => state.phase === 2 && clean(state), actions);
|
||||
const afterClosingDraw = structuredClone(game);
|
||||
assert.equal(nexusEmperorSellerPlayer(game, winner).ally, null);
|
||||
assert.ok(!game.players.some(player => player.faction === 'emperor'));
|
||||
game = advanceNexusEmperorSeller(game, state => !state.phaseOpening && !state.response &&
|
||||
state.decision?.kind === (source === 'cache' ? 'richeseCache' : 'richeseBlackMarket'), actions);
|
||||
const beforeOffer = structuredClone(game);
|
||||
const card = structuredClone(source === 'cache' ? game.richeseCache![0] : nexusEmperorSellerPlayer(game, seller).hand[0]);
|
||||
const round = game.richeseBidding!.event;
|
||||
game = stepNexusEmperorSeller(game, { actor: seller,
|
||||
action: { type: 'decision', event: round, card: card.id, method: 'silent' } }, actions);
|
||||
game = advanceNexusEmperorSeller(game, state => !state.phaseOpening && !state.response && !state.decision, actions);
|
||||
const lot = game.richeseAuction!.event;
|
||||
let beforeFinalBid = structuredClone(game);
|
||||
let beforeWinnerBid: Game | undefined;
|
||||
while (!game.richeseAuction!.outcome) {
|
||||
const auction = game.richeseAuction!;
|
||||
const actor = auction.order.find(id => auction.eligible.includes(id) && !Object.hasOwn(auction.sealed, id));
|
||||
assert.ok(actor);
|
||||
beforeFinalBid = structuredClone(game);
|
||||
if (actor === winner) beforeWinnerBid = structuredClone(game);
|
||||
game = stepNexusEmperorSeller(game, { actor,
|
||||
action: { type: 'richeseBid', event: lot, amount: actor === winner ? price : 0 } }, actions);
|
||||
}
|
||||
assert.ok(beforeWinnerBid);
|
||||
if (options.betrayal && options.betrayal !== 'before') {
|
||||
while (game.pendingRicheseBetrayal) {
|
||||
const receipt = game.pendingRicheseBetrayal.receipt;
|
||||
const actor = receipt.required.find(id => !receipt.passed.includes(id));
|
||||
assert.ok(actor);
|
||||
game = stepNexusEmperorSeller(game, { actor, action: {
|
||||
type: actor === 'guild' && options.betrayal === 'bank' ? 'richeseBetrayalUse' : 'richeseBetrayalPass',
|
||||
event: receipt.event,
|
||||
} }, actions);
|
||||
}
|
||||
}
|
||||
if (options.betrayal === 'before') assert.ok(game.pendingRicheseBetrayal);
|
||||
else {
|
||||
assert.equal(game.decision?.kind, 'nexusEmperorSellerPurchase');
|
||||
assert.equal(game.decision.player, winner);
|
||||
}
|
||||
assert.equal(game.richeseAuction!.cardId, card.id);
|
||||
assert.deepEqual(game.richeseAuction!.outcome, { kind: 'sold', winner, amount: price });
|
||||
assert.ok(nexusEmperorSellerPlayer(game, winner).spice >= price);
|
||||
assert.equal(nexusEmperorSellerPlayer(game, winner).hand.some(held => held.id === card.id), false);
|
||||
const wallets = Object.fromEntries(game.players.map(player => [player.id, player.spice]));
|
||||
const originalCards = nexusEmperorSellerCards(game);
|
||||
nexusEmperorSellerInventory(game);
|
||||
return { game, event: game.decision?.kind === 'nexusEmperorSellerPurchase' ? game.decision.event :
|
||||
game.pendingRicheseBetrayal!.receipt.event, winner, seller, source, card, price, round, lot,
|
||||
wallets, originalCards, actions, chronology: { initial, undealt, afterSetup, firstMentat,
|
||||
worm, alliance, draw, afterClosingDraw, beforeOffer, beforeWinnerBid, beforeFinalBid } };
|
||||
}
|
||||
@@ -95,6 +95,8 @@ export function nextStrongholdFactionsNativeStep(game: Game): StrongholdFactions
|
||||
return { actor, action: { type: 'nexusFremenProtection', event: decision.event, use: false } };
|
||||
if (decision.kind === 'nexusEmperorPayment')
|
||||
return { actor, action: { type: 'nexusEmperorPayment', event: decision.event, use: false } };
|
||||
if (decision.kind === 'nexusEmperorSellerPurchase')
|
||||
return { actor, action: { type: 'nexusEmperorSellerPurchase', event: decision.event, use: false } };
|
||||
if (decision.kind === 'ixSetup') return { actor, action: { type: 'decision', card: game.ixSetupCards![0].id } };
|
||||
if (decision.kind === 'mobileStronghold') return { actor, action: decision.placement ? { type: 'decision', location: 'polar_sink:0' } : { type: 'decision', decline: true } };
|
||||
if (decision.kind === 'ixTechnology' || decision.kind === 'ixSubstitution' || decision.kind === 'captureOffer' || decision.kind === 'fullPlanOffer' || decision.kind === 'choamTradeReply')
|
||||
|
||||
@@ -80,7 +80,9 @@ void test('a prior normal purchase cannot authorize spending Emperor Nexus on a
|
||||
reject(g, owner, first, /current Emperor Nexus/i);
|
||||
const second = { type: 'nexusEmperorPurchase', event: viewGame(g, owner).nexusEmperorSecretAlly!.event };
|
||||
const done = applyAction(g, owner, second);
|
||||
assert.equal(done.nexusEmperorPurchaseHistory?.[0].auctionIndex, 1);
|
||||
const receipt = done.nexusEmperorPurchaseHistory?.[0];
|
||||
assert.ok(receipt && 'auctionIndex' in receipt);
|
||||
assert.equal(receipt.auctionIndex, 1);
|
||||
});
|
||||
|
||||
void test('invalid normal-lot custody and full winning hand cannot spend the physical Nexus card', () => {
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { createGame, newPlayer, handLimit, type Game } from '../game/engine';
|
||||
import { createNexusCards, drawNexusCard } from '../game/nexus-cards';
|
||||
import { createRicheseAuction, submitRicheseBid } from '../game/richese-auction';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { quoteRicheseSettlement } from '../game/richese-settlement';
|
||||
import {
|
||||
EmperorNexusPurchaseError, createEmperorNexusSellerPurchase,
|
||||
emperorNexusSellerPurchaseEvent, emperorNexusPurchaseEvent, quoteEmperorNexusSellerPurchase,
|
||||
signEmperorNexusPurchase, validateEmperorNexusPurchase,
|
||||
type EmperorNexusBankPurchase, type EmperorNexusSellerPurchaseInput, type EmperorNexusSellerPurchase,
|
||||
} from '../game/nexus-emperor-purchase';
|
||||
import {
|
||||
auctionEmperorIncomeAmount, quoteAuctionContinuation, AuctionContinuationError,
|
||||
type AuctionContinuationInput,
|
||||
} from '../game/auction-continuation-quote';
|
||||
|
||||
function original(source: 'cache' | 'blackMarket' = 'cache', self = false) {
|
||||
const g = createGame('SELLERQUOTE', newPlayer('r', 'Richese', 'richese'), source === 'blackMarket', ['choam']);
|
||||
g.players.push(newPlayer('h', 'Harkonnen', 'harkonnen'), newPlayer('c', 'CHOAM', 'choam'));
|
||||
g.order = g.players.map(p => p.id);
|
||||
g.status = 'playing';
|
||||
g.phase = 3;
|
||||
g.turn = 2;
|
||||
for (const p of g.players) { p.hand = []; p.spice = 10; }
|
||||
g.deck = [];
|
||||
g.discard = [];
|
||||
g.auction = null;
|
||||
const card = source === 'cache' ? richeseCards()[0] : baseDeck()[0];
|
||||
g.richeseCache = source === 'cache' ? [card] : [];
|
||||
if (source === 'blackMarket') g.players[0].hand.push(card);
|
||||
const winner = self ? 'r' : 'h';
|
||||
let lot = createRicheseAuction({
|
||||
event: 'original-lot', cardId: card.id, source, owner: 'r', method: 'silent',
|
||||
eligible: g.order, order: g.order, tieOrder: g.order,
|
||||
});
|
||||
for (const actor of g.order)
|
||||
lot = submitRicheseBid(lot, { event: lot.event, actor, amount: actor === winner ? 6 : 0 }, 10);
|
||||
g.richeseAuction = lot;
|
||||
g.richeseBidding = {
|
||||
owner: 'r', event: 'original-round', turn: g.turn, stage: 'lot',
|
||||
position: source === 'cache' ? 'first' : null, normalCount: 3,
|
||||
blackMarketSold: false, cacheCanceled: false,
|
||||
};
|
||||
g.richeseFunding = { [winner]: { amount: 6, allyPayment: 0, donor: null } };
|
||||
const cards = createNexusCards(g.players, () => 0);
|
||||
cards.deck = ['emperor', ...cards.deck.filter(c => c !== 'emperor')];
|
||||
g.nexusCards = { cards: drawNexusCard(cards, winner, g.players, () => 0), phase: null };
|
||||
const buyer = g.players.find(p => p.id === winner)!;
|
||||
const quote = quoteRicheseSettlement({
|
||||
lot, owner: { id: 'r', handCount: g.players[0].hand.length, handLimit: 4 },
|
||||
winner: { id: winner, handCount: buyer.hand.length, handLimit: handLimit(buyer), spice: buyer.spice },
|
||||
card, funding: g.richeseFunding[winner],
|
||||
});
|
||||
assert.equal(quote.kind, 'sold');
|
||||
if (quote.kind !== 'sold') throw new Error('Expected native original sale.');
|
||||
const input: EmperorNexusSellerPurchaseInput = { round: g.richeseBidding.event, lot, quote, bank: false };
|
||||
return { g, input, winner };
|
||||
}
|
||||
|
||||
function continuation(g: Game, input: EmperorNexusSellerPurchaseInput, free = true): AuctionContinuationInput {
|
||||
return {
|
||||
status: g.status, phase: g.phase, turn: g.turn, advanced: g.advanced,
|
||||
players: g.players.map(p => ({ ...p, handLimit: handLimit(p) })), order: g.order,
|
||||
physicalCards: [...g.players.flatMap(p => p.hand), ...(g.richeseCache ?? [])],
|
||||
auction: null, richeseAuction: input.lot, richeseBidding: g.richeseBidding,
|
||||
richeseCacheCount: g.richeseCache?.length,
|
||||
sale: { winner: input.quote.winner, amount: input.quote.amount, free,
|
||||
origin: input.lot.source, seller: input.lot.owner, ...(input.bank ? { recipient: 'bank' as const } : {}) },
|
||||
};
|
||||
}
|
||||
|
||||
void test('original cache and Black Market price proofs sign unchanged personally held spice without mutating the source', () => {
|
||||
for (const source of ['cache', 'blackMarket'] as const) {
|
||||
const { g, input, winner } = original(source), before = structuredClone(g);
|
||||
const offer = quoteEmperorNexusSellerPurchase(g, input, winner);
|
||||
assert.equal(offer.blocked, null);
|
||||
assert.equal(offer.price, 6);
|
||||
assert.equal(offer.source, source);
|
||||
const receipt = createEmperorNexusSellerPurchase(g, input);
|
||||
assert.equal(receipt.version, 2);
|
||||
assert.equal(receipt.owner, winner);
|
||||
assert.equal(receipt.seller, 'r');
|
||||
assert.equal(receipt.beforeSpice, 10);
|
||||
assert.equal(receipt.afterSpice, 10);
|
||||
assert.equal(receipt.event, emperorNexusSellerPurchaseEvent(2, winner, 0, 'original-round', 'original-lot', source, 6));
|
||||
assert.equal(Object.hasOwn(receipt, 'auctionIndex'), false);
|
||||
assert.deepEqual(g, before);
|
||||
// Historical evidence deliberately ignores future wallets and card transfers.
|
||||
g.turn = 4;
|
||||
g.players.find(p => p.id === winner)!.spice = 0;
|
||||
g.richeseAuction = null;
|
||||
g.richeseCache = [];
|
||||
assert.doesNotThrow(() => validateEmperorNexusPurchase(g, receipt));
|
||||
}
|
||||
});
|
||||
|
||||
void test('native Richese self cache purchase can retain its personally affordable price', () => {
|
||||
const { g, input } = original('cache', true);
|
||||
assert.equal(createEmperorNexusSellerPurchase(g, input).owner, 'r');
|
||||
const q = quoteAuctionContinuation(continuation(g, input), { kind: 'sale', free: true });
|
||||
assert.deepEqual(q.steps, []);
|
||||
assert.deepEqual(q.next, { kind: 'richeseEnd', source: 'cache', after: 'normalPool' });
|
||||
});
|
||||
|
||||
void test('personal-price, exact source, full hand and physical singleton corruption fail closed', () => {
|
||||
const changes: ((g: Game, input: EmperorNexusSellerPurchaseInput) => void)[] = [
|
||||
g => { g.players[1].spice = 5; },
|
||||
(_g, input) => { input.quote.amount = 5; },
|
||||
(_g, input) => { input.quote.ownPayment = 5; },
|
||||
(_g, input) => { input.quote.allyPayment = 1; },
|
||||
(_g, input) => { input.round = 'other-round'; },
|
||||
(_g, input) => { input.lot = { ...input.lot, event: 'other-lot' }; },
|
||||
(_g, input) => { input.lot = { ...input.lot, bid: 5 }; },
|
||||
g => { g.richeseFunding!.h.donor = 'c'; },
|
||||
g => { g.richeseFunding!.h.amount = 5; },
|
||||
g => { g.deck.push(g.richeseCache![0]); },
|
||||
g => { g.richeseCache = []; },
|
||||
g => { g.players[1].hand = baseDeck().slice(0, 8); },
|
||||
g => { g.nexusCards!.cards!.deck.push('emperor'); },
|
||||
(_g, input) => { input.quote.card = { ...input.quote.card, name: 'substituted face' }; },
|
||||
(_g, input) => { Object.assign(input, { extra: true }); },
|
||||
(_g, input) => { Object.assign(input.quote, { extra: true }); },
|
||||
];
|
||||
for (const change of changes) {
|
||||
const { g, input } = original();
|
||||
change(g, input);
|
||||
assert.throws(() => createEmperorNexusSellerPurchase(g, input), EmperorNexusPurchaseError);
|
||||
}
|
||||
const { g, input } = original('blackMarket');
|
||||
g.advanced = false;
|
||||
assert.throws(() => createEmperorNexusSellerPurchase(g, input), EmperorNexusPurchaseError);
|
||||
assert.throws(() => original('blackMarket', true), /Black Market self-purchase/);
|
||||
});
|
||||
|
||||
void test('private card entitlement and alliances cannot authorize retention, even with enough visible spice', () => {
|
||||
const { g, input, winner } = original();
|
||||
const cards = g.nexusCards!.cards!;
|
||||
cards.hands[winner] = 'guild';
|
||||
cards.deck = ['emperor', ...cards.deck.filter(c => c !== 'guild')];
|
||||
assert.match(quoteEmperorNexusSellerPurchase(g, input, winner).blocked!, /real Emperor/);
|
||||
cards.deck = ['guild', ...cards.deck.filter(c => c !== 'emperor')];
|
||||
cards.hands[winner] = 'emperor';
|
||||
g.players[1].ally = 'c';
|
||||
g.players[2].ally = winner;
|
||||
assert.match(quoteEmperorNexusSellerPurchase(g, input, winner).blocked!, /unallied/);
|
||||
input.quote.allyPayment = 2;
|
||||
input.quote.ownPayment = 4;
|
||||
g.richeseFunding!.h = { amount: 6, allyPayment: 2, donor: 'c' };
|
||||
g.aid.c = { recipient: winner, amount: 2 };
|
||||
g.players[1].spice = 4;
|
||||
assert.throws(() => createEmperorNexusSellerPurchase(g, input), /unallied/);
|
||||
});
|
||||
|
||||
void test('free original seller continuation produces no seller credit or income while preserving native bonus and end boundaries', () => {
|
||||
for (const source of ['cache', 'blackMarket'] as const) {
|
||||
const { g, input } = original(source);
|
||||
for (const bank of [false, true]) {
|
||||
input.bank = bank;
|
||||
const ctx = continuation(g, input), before = structuredClone(ctx);
|
||||
const q = quoteAuctionContinuation(ctx, { kind: 'sale', free: true });
|
||||
assert.equal(q.sale.amount, 6);
|
||||
assert.equal(auctionEmperorIncomeAmount(q.sale), 0);
|
||||
assert.deepEqual(q.steps, []);
|
||||
assert.deepEqual(q.next, { kind: 'response', response: { kind: 'harkonnenBonus', owner: 'h', passed: [] } });
|
||||
const end = quoteAuctionContinuation(ctx, { kind: 'next' });
|
||||
assert.deepEqual(end.next, source === 'cache'
|
||||
? { kind: 'richeseEnd', source: 'cache', after: 'normalPool' }
|
||||
: { kind: 'richeseEnd', source: 'blackMarket', after: 'declaration', blackMarketSold: true });
|
||||
assert.deepEqual(ctx, before);
|
||||
const paid = quoteAuctionContinuation(continuation(g, input, false), { kind: 'sale', free: false });
|
||||
assert.deepEqual(paid.steps, bank ? [] : [{ kind: 'sellerCredit', player: 'r', amount: 6, balance: 16 }]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('a free seller lot cannot fabricate a donor invoice or payer-derived income', () => {
|
||||
for (const patch of [
|
||||
{ richeseContribution: 1 }, { richeseContribution: 0 },
|
||||
{ occupiedContributions: [{ payer: 'h', amount: 6 }] },
|
||||
{ bureaucratPaymentEvent: 'fake-paid-source' },
|
||||
{ spiceBankerIncomePayment: {} }, { bureaucratPayment: {} },
|
||||
]) {
|
||||
const { g, input } = original();
|
||||
const ctx = continuation(g, input);
|
||||
Object.assign(ctx.sale!, patch);
|
||||
assert.throws(() => quoteAuctionContinuation(ctx, { kind: 'sale', free: true }), AuctionContinuationError);
|
||||
}
|
||||
});
|
||||
|
||||
void test('strict seller receipt source, price and retained balance corruption rejects even after re-signing', () => {
|
||||
const { g, input } = original();
|
||||
const receipt = createEmperorNexusSellerPurchase(g, input);
|
||||
const invalid = (patch: Partial<EmperorNexusSellerPurchase>, resign = false) => {
|
||||
const changed = { ...receipt, ...patch };
|
||||
if (resign) changed.signature = signEmperorNexusPurchase(changed);
|
||||
assert.throws(() => validateEmperorNexusPurchase(g, changed), EmperorNexusPurchaseError);
|
||||
};
|
||||
for (const patch of [
|
||||
{ origin: 'blackMarket' as const }, { seller: 'c' }, { round: 'other-round' },
|
||||
{ lot: 'other-lot' }, { price: 7 }, { card: 'other-card' }, { owner: 'c' }, { sequence: 1 },
|
||||
]) invalid(patch);
|
||||
for (const patch of [
|
||||
{ version: 1 as never }, { price: 0 }, { price: 11 }, { price: 1.5 },
|
||||
{ beforeSpice: 5, afterSpice: 5 }, { afterSpice: 9 }, { afterSpice: 16 },
|
||||
{ seller: 'c' }, { phase: 4 as never }, { turn: 0 }, { turn: 3 },
|
||||
{ lot: ' ' }, { round: ' ' }, { auctionIndex: 0 } as never,
|
||||
{ ownPayment: 6 } as never, { bankSubsidy: 6 } as never,
|
||||
]) invalid(patch, true);
|
||||
});
|
||||
|
||||
void test('normal bank receipts retain their exact original shape, event and typed signature', () => {
|
||||
const { g } = original();
|
||||
const receipt: EmperorNexusBankPurchase = {
|
||||
kind: 'purchase', event: emperorNexusPurchaseEvent(1, 'h', 0, 2, 6),
|
||||
owner: 'h', turn: 1, phase: 3, sequence: 0, faction: 'harkonnen', advanced: false,
|
||||
price: 6, auctionIndex: 2, card: 'historical-bank-card', beforeSpice: 10, afterSpice: 10, signature: '',
|
||||
};
|
||||
const expected = JSON.stringify([
|
||||
'emperorNexusPurchase', 'purchase', receipt.event, 'h', 1, 3, 0, 'harkonnen',
|
||||
false, 6, 2, 'historical-bank-card', 10, 10,
|
||||
]);
|
||||
receipt.signature = signEmperorNexusPurchase(receipt);
|
||||
assert.equal(receipt.signature, expected);
|
||||
assert.doesNotThrow(() => validateEmperorNexusPurchase(g, receipt));
|
||||
const hybrid = { ...receipt, version: 2, origin: 'cache', seller: 'r', round: 'round', lot: 'lot' } as never;
|
||||
assert.throws(() => validateEmperorNexusPurchase(g, hybrid), EmperorNexusPurchaseError);
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
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 {
|
||||
advanceNexusEmperorSeller, createNexusEmperorSellerFixture, nexusEmperorSellerCards,
|
||||
nexusEmperorSellerInventory, nexusEmperorSellerPlayer, nextNexusEmperorSellerStep,
|
||||
stepNexusEmperorSeller, type NexusEmperorSellerFixture,
|
||||
} from './fixture-nexus-emperor-seller';
|
||||
|
||||
function rejectsUnchanged(game: Game, actor: string, action: Action): void {
|
||||
const original = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, original, 'Refusal must precede spending or native lot mutation');
|
||||
}
|
||||
function finishOriginalLot(f: NexusEmperorSellerFixture, game: Game): { game: Game; bonuses: number } {
|
||||
let bonuses = 0;
|
||||
let inBonus = false;
|
||||
for (let n = 0; n < 100; n++) {
|
||||
if (!game.richeseAuction && !game.currentAuctionSale) return { game, bonuses };
|
||||
const bonus = game.response?.kind === 'harkonnenBonus';
|
||||
if (bonus && !inBonus) bonuses++;
|
||||
inBonus = bonus;
|
||||
assert.ok(!game.decision, 'The original paid/free acquisition cannot reopen buyer confirmation');
|
||||
game = stepNexusEmperorSeller(game, nextNexusEmperorSellerStep(game));
|
||||
}
|
||||
throw Error(`Original ${f.source} acquisition did not reach its source successor`);
|
||||
}
|
||||
const scenarios = [
|
||||
{ advanced: false, source: 'cache' as const },
|
||||
{ advanced: true, source: 'cache' as const },
|
||||
{ advanced: true, source: 'blackMarket' as const },
|
||||
];
|
||||
|
||||
for (const scenario of scenarios) for (const use of [false, true]) void test(
|
||||
`${scenario.advanced ? 'Advanced' : 'Basic'} original ${scenario.source}: ${use ? 'retain spice without seller income' : 'ordinary paid seller income'} and native Harkonnen bonus once`, () => {
|
||||
const f = createNexusEmperorSellerFixture(scenario), original = structuredClone(f.game);
|
||||
const offer = viewGame(f.game, f.winner).nexusEmperorSellerPurchase!;
|
||||
assert.equal(offer.canUse, true); assert.equal(offer.blocked, null);
|
||||
for (const rival of f.game.players.filter(player => player.id !== f.winner))
|
||||
assert.equal(viewGame(f.game, rival.id).nexusEmperorSellerPurchase, null);
|
||||
const action = { type: 'nexusEmperorSellerPurchase', event: f.event, use };
|
||||
let game = stepNexusEmperorSeller(f.game, { actor: f.winner, action });
|
||||
assert.deepEqual(f.game, original);
|
||||
assert.equal(nexusEmperorSellerPlayer(game, f.winner).spice, f.wallets[f.winner] - (use ? 0 : f.price));
|
||||
assert.equal(nexusEmperorSellerPlayer(game, f.winner).hand.filter(card => card.id === f.card.id).length, 1);
|
||||
assert.equal(game.response?.kind, 'harkonnenBonus', 'The existing native bonus cancellation boundary must remain');
|
||||
assert.equal(game.currentAuctionSale!.origin, f.source);
|
||||
assert.equal(game.currentAuctionSale!.free, use);
|
||||
if (use) {
|
||||
assert.equal(game.currentAuctionSale!.richeseContribution, undefined);
|
||||
assert.equal(game.currentAuctionSale!.spiceBankerIncomePayment, undefined);
|
||||
assert.equal(game.currentAuctionSale!.bureaucratPayment, undefined);
|
||||
assert.equal(game.currentAuctionSale!.bureaucratPaymentEvent, undefined);
|
||||
}
|
||||
assert.equal(game.currentAuctionSale!.seller, f.seller);
|
||||
assert.equal(game.richeseAuction!.event, f.lot);
|
||||
assert.equal(game.richeseAuction!.cardId, f.card.id);
|
||||
assert.deepEqual(game.richeseAuction!.outcome, original.richeseAuction!.outcome);
|
||||
assert.equal(game.pendingNexusEmperorSellerPurchase, null);
|
||||
assert.equal(game.nexusCards!.cards!.hands[f.winner], use ? null : 'emperor');
|
||||
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'emperor').length, use ? 1 : 0);
|
||||
assert.equal(nexusEmperorSellerPlayer(game, f.seller).spice, f.wallets[f.seller] + (use ? 0 : f.price));
|
||||
for (const rival of game.players.filter(player => player.id !== f.winner && player.id !== f.seller))
|
||||
assert.equal(rival.spice, f.wallets[rival.id], 'There is no bank-funded substitute income');
|
||||
const bonusCard = game.deck[0];
|
||||
assert.ok(bonusCard);
|
||||
const settled = finishOriginalLot(f, game);
|
||||
game = settled.game;
|
||||
assert.equal(settled.bonuses, 1);
|
||||
assert.equal(nexusEmperorSellerPlayer(game, f.winner).hand.length,
|
||||
nexusEmperorSellerPlayer(original, f.winner).hand.length + 2);
|
||||
assert.equal(nexusEmperorSellerPlayer(game, f.winner).hand.filter(card => card.id === bonusCard.id).length, 1);
|
||||
assert.equal(nexusEmperorSellerPlayer(game, f.winner).hand.filter(card => card.id === f.card.id).length, 1);
|
||||
if (f.source === 'cache') {
|
||||
assert.equal(game.richeseCache!.length, original.richeseCache!.length - 1);
|
||||
assert.ok(!game.richeseCache!.some(card => card.id === f.card.id));
|
||||
assert.equal(game.richeseBidding!.stage, 'normal');
|
||||
assert.ok(game.auction, 'The original reduced ordinary pool follows the first cache lot');
|
||||
} else {
|
||||
assert.equal(nexusEmperorSellerPlayer(game, f.seller).hand.length,
|
||||
nexusEmperorSellerPlayer(original, f.seller).hand.length - 1);
|
||||
assert.deepEqual(game.richeseCache, original.richeseCache);
|
||||
assert.equal(game.richeseBidding!.blackMarketSold, true);
|
||||
assert.equal(game.decision!.kind, 'richeseDeclaration');
|
||||
}
|
||||
assert.deepEqual(nexusEmperorSellerCards(game), f.originalCards);
|
||||
nexusEmperorSellerInventory(game);
|
||||
if (use) {
|
||||
const receipt = game.nexusEmperorPurchaseHistory!.at(-1)!;
|
||||
assert.ok('version' in receipt && receipt.version === 2);
|
||||
assert.equal(receipt.afterSpice, receipt.beforeSpice);
|
||||
assert.equal(game.nexusEmperorPurchaseEvents!.filter(event => event === f.event).length, 1);
|
||||
} else {
|
||||
assert.deepEqual(game.nexusEmperorPurchaseHistory ?? [], original.nexusEmperorPurchaseHistory ?? []);
|
||||
assert.deepEqual(game.nexusEmperorPurchaseEvents ?? [], original.nexusEmperorPurchaseEvents ?? []);
|
||||
}
|
||||
rejectsUnchanged(game, f.winner, action);
|
||||
},
|
||||
);
|
||||
|
||||
for (const advanced of [false, true]) void test(
|
||||
`${advanced ? 'Advanced' : 'Basic'} genuine Richese self-cache purchase retains the original bank destination without bank money`, () => {
|
||||
const f = createNexusEmperorSellerFixture({ advanced, winnerFaction: 'richese' });
|
||||
const beforeHand = nexusEmperorSellerPlayer(f.game, f.winner).hand.length;
|
||||
const original = structuredClone(f.game);
|
||||
const game = stepNexusEmperorSeller(f.game, { actor: f.winner,
|
||||
action: { type: 'nexusEmperorSellerPurchase', event: f.event, use: true } });
|
||||
assert.deepEqual(f.game, original);
|
||||
for (const player of game.players) assert.equal(player.spice, f.wallets[player.id]);
|
||||
assert.equal(nexusEmperorSellerPlayer(game, f.winner).hand.length, beforeHand + 1);
|
||||
assert.equal(nexusEmperorSellerPlayer(game, f.winner).hand.filter(card => card.id === f.card.id).length, 1);
|
||||
assert.equal(game.richeseCache!.length, f.game.richeseCache!.length - 1);
|
||||
assert.equal(game.richeseBidding!.stage, 'normal');
|
||||
assert.equal(game.response?.kind === 'emperorIncome', false);
|
||||
assert.equal(game.response?.kind === 'harkonnenBonus', false);
|
||||
nexusEmperorSellerInventory(game);
|
||||
},
|
||||
);
|
||||
|
||||
void test('original buyer rejects stale, foreign, malformed and dual-cost actions without spending or source movement', () => {
|
||||
const f = createNexusEmperorSellerFixture({ advanced: true, source: 'blackMarket' });
|
||||
for (const rival of f.game.players.filter(player => player.id !== f.winner))
|
||||
rejectsUnchanged(f.game, rival.id, { type: 'nexusEmperorSellerPurchase', event: f.event, use: true });
|
||||
for (const action of [
|
||||
{ type: 'nexusEmperorSellerPurchase', event: 'stale', use: true },
|
||||
{ type: 'nexusEmperorSellerPurchase', event: f.event, use: true, price: 0 },
|
||||
{ type: 'nexusEmperorSellerPurchase', event: f.event, use: true, allyPayment: 1 },
|
||||
{ type: 'nexusEmperorSellerPurchase', event: f.event, use: true, card: f.card.id },
|
||||
{ type: 'nexusEmperorSellerPurchase', event: f.event, use: true, cost: f.card.id },
|
||||
{ type: 'nexusEmperorSellerPurchase', event: f.event, use: 'true' },
|
||||
{ type: 'nexusEmperorSellerPurchase', event: f.event },
|
||||
{ type: 'decision', event: f.event, use: true },
|
||||
{ type: 'nexusEmperorPurchase', event: f.event },
|
||||
]) rejectsUnchanged(f.game, f.winner, action);
|
||||
assert.equal(f.game.nexusCards!.cards!.hands[f.winner], 'emperor');
|
||||
nexusEmperorSellerInventory(f.game);
|
||||
});
|
||||
|
||||
void test('a real Emperor holder cannot commit a personally unaffordable native seller bid', () => {
|
||||
for (const scenario of scenarios) {
|
||||
const f = createNexusEmperorSellerFixture(scenario);
|
||||
const before = f.chronology.beforeWinnerBid;
|
||||
const ownSpice = nexusEmperorSellerPlayer(before, f.winner).spice;
|
||||
assert.equal(before.nexusCards!.cards!.hands[f.winner], 'emperor');
|
||||
rejectsUnchanged(before, f.winner, { type: 'richeseBid', event: f.lot, amount: ownSpice + 1 });
|
||||
assert.equal(nexusEmperorSellerPlayer(before, f.winner).spice, ownSpice);
|
||||
assert.equal(nexusEmperorSellerPlayer(before, f.winner).hand.some(card => card.id === f.card.id), false);
|
||||
nexusEmperorSellerInventory(before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('real irrelevant closing draw has the same public buyer Pay boundary, never an Emperor spend entitlement', () => {
|
||||
const f = createNexusEmperorSellerFixture({ nexusCard: 'guild' });
|
||||
assert.equal(f.game.decision!.kind, 'nexusEmperorSellerPurchase');
|
||||
assert.equal(f.game.decision!.player, f.winner);
|
||||
assert.equal(!!viewGame(f.game, f.winner).nexusEmperorSellerPurchase?.canUse, false);
|
||||
rejectsUnchanged(f.game, f.winner, { type: 'nexusEmperorSellerPurchase', event: f.event, use: true });
|
||||
const settled = finishOriginalLot(f, stepNexusEmperorSeller(f.game, { actor: f.winner,
|
||||
action: { type: 'nexusEmperorSellerPurchase', event: f.event, use: false } })).game;
|
||||
assert.equal(nexusEmperorSellerPlayer(settled, f.winner).spice, f.wallets[f.winner] - f.price);
|
||||
assert.equal(nexusEmperorSellerPlayer(settled, f.seller).spice, f.wallets[f.seller] + f.price);
|
||||
assert.equal(settled.nexusCards!.cards!.hands[f.winner], 'guild');
|
||||
nexusEmperorSellerInventory(settled);
|
||||
});
|
||||
|
||||
void test('naturally setup-dealt Truthtrance suspends the original seller purchase and restores its exact event before Use', () => {
|
||||
const f = createNexusEmperorSellerFixture({ advanced: true, source: 'blackMarket', truthtrance: true });
|
||||
const card = nexusEmperorSellerPlayer(f.game, 'choam').hand.find(held => held.effect === 'truthtrance');
|
||||
assert.ok(card);
|
||||
assert.ok(nexusEmperorSellerPlayer(f.chronology.afterSetup, 'choam').hand.some(held => held.id === card.id));
|
||||
let game = stepNexusEmperorSeller(f.game, { actor: 'choam', action: { type: 'card', card: card.id } });
|
||||
assert.equal(game.truthtrance!.stage, 'priority');
|
||||
assert.equal(game.pendingNexusEmperorSellerPurchase!.event, f.event);
|
||||
rejectsUnchanged(game, f.winner, { type: 'nexusEmperorSellerPurchase', event: f.event, use: true });
|
||||
while (game.truthtrance?.stage === 'priority') {
|
||||
const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id))!.id;
|
||||
game = stepNexusEmperorSeller(game, { actor, action: { type: 'truthPass' } });
|
||||
}
|
||||
const name = nexusEmperorSellerPlayer(game, f.winner).hand[0].name;
|
||||
game = stepNexusEmperorSeller(game, { actor: 'choam', action: { type: 'truthAsk',
|
||||
question: { kind: 'fact', target: f.winner, fact: { kind: 'hand', name } } } });
|
||||
assert.equal(viewGame(game, f.winner).truthAnswer, 'yes');
|
||||
game = stepNexusEmperorSeller(game, { actor: f.winner, action: { type: 'truthAnswer', answer: 'yes' } });
|
||||
assert.equal(game.truthtrance, null);
|
||||
assert.equal(game.decision!.kind, 'nexusEmperorSellerPurchase');
|
||||
assert.equal(game.decision!.event, f.event);
|
||||
assert.equal(game.pendingNexusEmperorSellerPurchase!.event, f.event);
|
||||
assert.equal(nexusEmperorSellerPlayer(game, f.winner).spice, f.wallets[f.winner]);
|
||||
assert.equal(game.discard.filter(held => held.id === card.id).length, 1);
|
||||
game = finishOriginalLot(f, stepNexusEmperorSeller(game, { actor: f.winner,
|
||||
action: { type: 'nexusEmperorSellerPurchase', event: f.event, use: true } })).game;
|
||||
assert.equal(nexusEmperorSellerPlayer(game, f.winner).spice, f.wallets[f.winner]);
|
||||
assert.equal(nexusEmperorSellerPlayer(game, f.seller).spice, f.wallets[f.seller]);
|
||||
nexusEmperorSellerInventory(game);
|
||||
});
|
||||
|
||||
void test('minimal original Easy bot can legally resolve both genuine buyer entitlements', () => {
|
||||
for (const nexusCard of ['emperor', 'guild'] as const) {
|
||||
const f = createNexusEmperorSellerFixture({ nexusCard });
|
||||
const view = viewGame(f.game, f.winner);
|
||||
view.players.find(player => player.id === f.winner)!.bot = 'Easy';
|
||||
const action = botActions(view)[0];
|
||||
assert.ok(action, 'The actual paused buyer needs a legal bot continuation');
|
||||
const game = finishOriginalLot(f, stepNexusEmperorSeller(f.game, { actor: f.winner, action })).game;
|
||||
assert.equal(nexusEmperorSellerPlayer(game, f.winner).hand.filter(card => card.id === f.card.id).length, 1);
|
||||
assert.equal(nexusEmperorSellerPlayer(game, f.winner).spice, f.wallets[f.winner] - (nexusCard === 'emperor' ? 0 : f.price));
|
||||
assert.equal(nexusEmperorSellerPlayer(game, f.seller).spice, f.wallets[f.seller] + (nexusCard === 'emperor' ? 0 : f.price));
|
||||
nexusEmperorSellerInventory(game);
|
||||
}
|
||||
});
|
||||
|
||||
void test('a retained original receipt remains historical through the next genuine turn and closing phases', () => {
|
||||
const f = createNexusEmperorSellerFixture();
|
||||
let game = finishOriginalLot(f, stepNexusEmperorSeller(f.game, { actor: f.winner,
|
||||
action: { type: 'nexusEmperorSellerPurchase', event: f.event, use: true } })).game;
|
||||
const receipt = structuredClone(game.nexusEmperorPurchaseHistory!.at(-1));
|
||||
game = advanceNexusEmperorSeller(game, state => state.turn === 3 && state.phase === 2 &&
|
||||
!state.phaseOpening && !state.response && !state.decision);
|
||||
assert.deepEqual(game.nexusEmperorPurchaseHistory!.at(-1), receipt);
|
||||
for (const player of game.players) assert.equal(viewGame(game, player.id).turn, 3);
|
||||
nexusEmperorSellerInventory(game);
|
||||
});
|
||||
|
||||
for (const betrayal of ['pass', 'bank'] as const) {
|
||||
void test(`original Richese reaction ${betrayal} precedes price retention without inventing a later payment`, () => {
|
||||
const f = createNexusEmperorSellerFixture({ advanced: true, betrayal });
|
||||
const before = structuredClone(f.game.players);
|
||||
assert.equal(f.game.richeseBetrayal?.completed.at(-1)?.stage, betrayal === 'bank' ? 'used' : 'passed');
|
||||
const free = stepNexusEmperorSeller(f.game, { actor: f.winner,
|
||||
action: { type: 'nexusEmperorSellerPurchase', event: f.event, use: true } });
|
||||
for (const p of free.players) assert.equal(p.spice, before.find(old => old.id === p.id)!.spice);
|
||||
const settled = finishOriginalLot(f, free);
|
||||
assert.equal(settled.bonuses, 1);
|
||||
assert.equal(nexusEmperorSellerPlayer(settled.game, f.winner).hand.filter(card => card.id === f.card.id).length, 1);
|
||||
assert.equal(settled.game.nexusCards!.cards!.discard.filter(card => card === 'emperor').length, 1);
|
||||
assert.equal(settled.game.nexusCards!.cards!.discard.filter(card => card === 'richese').length,
|
||||
betrayal === 'bank' ? 1 : 0);
|
||||
nexusEmperorSellerInventory(settled.game);
|
||||
});
|
||||
}
|
||||
|
||||
void test('Pay after original Richese bank diversion debits the buyer but pays no seller', () => {
|
||||
const f = createNexusEmperorSellerFixture({ advanced: true, source: 'blackMarket', betrayal: 'bank' });
|
||||
const paid = stepNexusEmperorSeller(f.game, { actor: f.winner,
|
||||
action: { type: 'nexusEmperorSellerPurchase', event: f.event, use: false } });
|
||||
assert.equal(nexusEmperorSellerPlayer(paid, f.winner).spice, f.wallets[f.winner] - f.price);
|
||||
assert.equal(nexusEmperorSellerPlayer(paid, f.seller).spice, f.wallets[f.seller]);
|
||||
assert.equal(paid.nexusCards!.cards!.hands[f.winner], 'emperor');
|
||||
const settled = finishOriginalLot(f, paid);
|
||||
assert.equal(settled.bonuses, 1);
|
||||
nexusEmperorSellerInventory(settled.game);
|
||||
});
|
||||
|
||||
void test('original Richese self-purchase discard occurs before any Emperor Nexus disposal or acquisition', () => {
|
||||
const f = createNexusEmperorSellerFixture({ advanced: true, winnerFaction: 'richese', betrayal: 'before' });
|
||||
let game = f.game;
|
||||
while (game.pendingRicheseBetrayal) {
|
||||
const receipt = game.pendingRicheseBetrayal.receipt;
|
||||
const actor = receipt.required.find(id => !receipt.passed.includes(id))!;
|
||||
game = stepNexusEmperorSeller(game, { actor, action: {
|
||||
type: actor === 'guild' ? 'richeseBetrayalUse' : 'richeseBetrayalPass', event: receipt.event,
|
||||
} });
|
||||
}
|
||||
assert.equal(game.pendingNexusEmperorSellerPurchase ?? null, null);
|
||||
assert.equal(game.nexusCards!.cards!.hands[f.winner], 'emperor');
|
||||
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'richese').length, 1);
|
||||
assert.equal(nexusEmperorSellerPlayer(game, f.winner).hand.some(card => card.id === f.card.id), false);
|
||||
assert.equal(game.discard.filter(card => card.id === f.card.id).length, 1);
|
||||
for (const p of game.players) assert.equal(p.spice, f.wallets[p.id]);
|
||||
nexusEmperorSellerInventory(game);
|
||||
});
|
||||
@@ -14,11 +14,16 @@ function reject(g: Game,id: string,action: Action) {
|
||||
assert.throws(() => applyAction(g,id,action));
|
||||
assert.equal(JSON.stringify(g),before);
|
||||
}
|
||||
function payOriginalSeller(g: Game): Game {
|
||||
const frame = g.pendingNexusEmperorSellerPurchase;
|
||||
return frame ? applyAction(g, frame.winner,
|
||||
{ type: 'nexusEmperorSellerPurchase', event: frame.event, use: false }) : g;
|
||||
}
|
||||
function passes(g: Game): Game {
|
||||
const receipt = g.pendingRicheseBetrayal!.receipt;
|
||||
for (const id of receipt.required)
|
||||
g = applyAction(g,id,{type:'richeseBetrayalPass',event:receipt.event});
|
||||
return g;
|
||||
return payOriginalSeller(g);
|
||||
}
|
||||
function census(g: Game): string[] {
|
||||
return [...g.deck,...g.discard,...(g.richeseCache ?? []),...(g.richeseRemoved ?? []),
|
||||
@@ -71,7 +76,7 @@ void test('cache and concealed Black Market diversion preserve original ally spl
|
||||
const before = reload(f.game), bonus = before.deck[0].id;
|
||||
assert.equal(before.pendingRicheseBetrayal!.receipt.invoice.ownPayment,4);
|
||||
assert.equal(before.pendingRicheseBetrayal!.receipt.invoice.donor,f.ally);
|
||||
const done = applyAction(reload(before),f.holder,{type:'richeseBetrayalUse',event:f.event});
|
||||
const done = payOriginalSeller(applyAction(reload(before),f.holder,{type:'richeseBetrayalUse',event:f.event}));
|
||||
assert.equal(player(done,f.buyer).spice,player(before,f.buyer).spice - 4);
|
||||
assert.equal(player(done,f.ally!).spice,player(before,f.ally!).spice);
|
||||
assert.equal(done.aid[f.ally!].amount,before.aid[f.ally!].amount - 2);
|
||||
@@ -195,7 +200,7 @@ void test('initializer admits only explicit fresh CHOAM/Richese Nexus compositio
|
||||
void test('diversion keeps native bonus cancellation and paid-sale ownership through JSON restore without replaying payment', () => {
|
||||
const f = createRicheseBetrayalFixture('sale',{bonusCounter:true});
|
||||
const before = reload(f.game);
|
||||
const paid = applyAction(reload(before),f.holder,{type:'richeseBetrayalUse',event:f.event});
|
||||
const paid = payOriginalSeller(applyAction(reload(before),f.holder,{type:'richeseBetrayalUse',event:f.event}));
|
||||
assert.equal(paid.response?.kind,'harkonnenBonus');
|
||||
assert.equal(paid.currentAuctionSale!.recipient,'bank');
|
||||
assert.equal(player(paid,f.target).spice,player(before,f.target).spice);
|
||||
@@ -229,7 +234,7 @@ void test('Richese can fund its buyer from escrow with no unpledged spice, with
|
||||
const f = createRicheseBetrayalFixture('sale',{source,allyPayment:2,allyIsSeller:true,allySpice:2});
|
||||
assert.equal(player(f.game,f.target).spice,0);
|
||||
assert.equal(f.game.aid[f.target].amount,2);
|
||||
const done = use ? applyAction(reload(f.game),f.holder,{type:'richeseBetrayalUse',event:f.event}) : passes(reload(f.game));
|
||||
const done = use ? payOriginalSeller(applyAction(reload(f.game),f.holder,{type:'richeseBetrayalUse',event:f.event})) : passes(reload(f.game));
|
||||
assert.equal(player(done,f.buyer).spice,player(f.game,f.buyer).spice - 4);
|
||||
// The authorized Advanced source sends Richese's own contribution to the
|
||||
// Emperor or, with no Emperor seated, to the Bank: the seller receives only
|
||||
|
||||
Reference in new issue
Block a user