diff --git a/components/richese-special-karama.tsx b/components/richese-special-karama.tsx index 9f6c67e3..bef61c97 100644 --- a/components/richese-special-karama.tsx +++ b/components/richese-special-karama.tsx @@ -36,6 +36,8 @@ export function RicheseSpecialKarama({ : !acquired ? 'No cache card is available for this purchase.' : null); + const nexus = purchase.emperorNexus; + const nexusReason = reason ?? nexus.blocked; return (
@@ -52,6 +54,15 @@ export function RicheseSpecialKarama({ its separate response. The remaining Richese card effects are still being implemented; inspect a card before choosing it.

+ {nexus.canUse && ( +

+ Provisional purchase composition: spend your Emperor Nexus with the + held Karama to retain the original 3-spice price. You still need 3 + uncommitted spice, and this still uses your once-per-game special + power. This creates no bank income, subsidy or refund and does not + protect the original Karama attempt from prevention. +

+ )} {me && me.hand?.length === me.handLimit && (

Provisional full-hand exchange: spending the held Karama frees the @@ -125,6 +136,26 @@ export function RicheseSpecialKarama({ > Spend Karama and 3 spice to buy {acquired?.name ?? 'a cache card'} + {nexus.canUse && ( + + )}

); diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 6b06e3ce..f4b44114 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -29,6 +29,22 @@ 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 joint native special purchase — 14 October.** Advanced +Richese may jointly spend its original Karama plus actual Emperor Nexus at +native purchase commit after proving three own uncommitted spice. Recommended +provisional composition keeps price, one private cache acquisition, native +once-use/full-hand/final-cache and original enclosing controls; no bank money, +reimbursement, ally bonus, fake auction or automatic public pause. +Affected13-file union112/112, types/lint/build and twelve native API profiles +pass. Actual390px retain5→5, full-hand4→4/spice3, final-cache1→0/turn9 and normal +Pay5→2 preserve both costs/one acquisition/once-use without overflow/errors. +Final depletion reaches ordinary Bidding; Easy keeps its native budget and +uses one real seller-income path. No fixture money or Kull admission invented. +[Canonical source/proof](NEXUS_EMPEROR_RULES.md#recommended-joint-richese-special-purchase--14-october-2026). +Full combinations, assurance and deployed/publication gates remain. No saved +or deployed room touched; proof receipts stay outside Git. Existing build +route-classification/chunk-size warnings remain. + **Emperor Secret Ally original Ambassador purchase — 14 October.** Original Richese trigger now offers uniform beneficiary Pay/eligible Use under visibly provisional outside-Bidding purchase composition. No new decline, bank payment, diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 8cbacbe2..d8331de5 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,39 @@ # Dune implementation status +## 14 October 2026 — Emperor Nexus joint native special acquisition + +Connected original Advanced Richese special Karama purchase to the printed +Emperor Nexus purchase panel without adding a Bidding qualifier. Private joint +action carries exact flags/event through the native normalized intent; actual +purchase commit spends original Karama plus Nexus, retains three own +uncommitted spice, transfers one exact unreserved cache card and sets native +once-use. No bank payment, reimbursement, ally bonus, fake auction or automatic +public pause. Original timing/enclosing controls, full-hand exchange and +final-cache continuation stay native. Version-four signed source history binds +both physical IDs, actual phase/price and unchanged wallet; future state is +not compared to old snapshots. Ordinary paid special acquisition is unchanged. + +Genuine native setup deals Richese Karama, real other-seat pact and closing +draw supply Emperor, and original paid/unbid acquisitions produce full-hand/ +final-cache sources. Last-cache chronology now records actual physical stock +removal, including original automatic unbid disposal when hand is full, instead +of pinning a nonexistent extra acknowledgement. Easy's existing six-spice +budget is reached by a real one-spice seller sale; no money or strategy is +changed. Source fixture types reflect native Homeworld shape and actual +transition snapshots. Receipt/action/chronology/default echo pins are deleted. +Existing native Kull producer excludes Richese; no unsupported frame is staged. + +Affected13-file union112/112 and types/lint/build pass. Twelve actual API profiles +cover phases2/4/5, paid/joint paths, full hand, final cache and intersection; +depletion reaches original ordinary Bidding without an empty compulsory lot. +Four actual390px surfaces prove keep5→5, full-hand4→4 at spice3, final-cache1→0 +at turn9 and paid5→2 with Guild Nexus retained. One original Karama cost, +actual Nexus cost, one cache card and once-use remain; no overflow/errors. +No saved/deployed room touched. Proof receipts stay outside Git; existing +build route/chunk warnings remain. Full combinations, broad assurance, +AI calibration and deployed/publication acceptance stay unfinished. +[Canonical composition/proof](NEXUS_EMPEROR_RULES.md#recommended-joint-richese-special-purchase--14-october-2026). + ## 14 October 2026 — Emperor Nexus original Ambassador purchase Connected the existing fixed-three Richese Ambassador purchase classification diff --git a/docs/NEXUS_CARD_RULES.md b/docs/NEXUS_CARD_RULES.md index c7f88042..17488e61 100644 --- a/docs/NEXUS_CARD_RULES.md +++ b/docs/NEXUS_CARD_RULES.md @@ -12,6 +12,18 @@ 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 joint special purchase — 14 October 2026 + +Original Advanced Richese special Karama now has a private joint option: +spend its actual activation and Emperor Nexus at commit, prove/retain three +own uncommitted spice and acquire one exact cache card. Native once-use, +capacity/full-hand exchange, reservations, enclosing controls and final-cache +continuation stay original. No bank payment/reimbursement, ally bonus, fake +auction or automatic public pause. Version-four receipts bind both physical +IDs and retained wallet. Visibly provisional purchase composition; existing +Richese/Kull admission is not broadened and complete combinations remain open. +[Source policy and changed-path evidence](NEXUS_EMPEROR_RULES.md#recommended-joint-richese-special-purchase--14-october-2026). + ## Recommended Emperor original Ambassador purchase — 14 October 2026 Original Richese Ambassador trigger now offers uniform beneficiary Pay or diff --git a/docs/NEXUS_EMPEROR_RULES.md b/docs/NEXUS_EMPEROR_RULES.md index e76666d9..b37e27d2 100644 --- a/docs/NEXUS_EMPEROR_RULES.md +++ b/docs/NEXUS_EMPEROR_RULES.md @@ -1,6 +1,6 @@ # Emperor Nexus: battle strength, purchase and revival -Primary-source audit, 10 September 2026, with a bounded purchase-runtime note added 27 September. The source contract does not certify the full mode. The [common Nexus rules](NEXUS_CARD_RULES.md) and current release gates remain in force; no new user ruling is assumed. +Primary-source audit, 10 September 2026, with bounded purchase/revival runtime amendments through 14 October. The source contract does not certify the full mode. The [common Nexus rules](NEXUS_CARD_RULES.md) and release gates remain; recommended compositions are not publisher clarification or separately answered numerical rulings. ## Authority @@ -142,6 +142,41 @@ CHOAM's original closing market only sells to the bank or trades held cards; it is not a missing direct purchase producer. Do not invent an Emperor Nexus purchase there. Other real purchase sources retain their separate contracts. +### Recommended joint Richese special purchase — 14 October 2026 + +The [original special Karama source](RICHESE_ACQUISITION_RULES.md#1-advanced-special-karama-purchase) +explicitly purchases one secretly chosen cache card for three own spice. +The Emperor Nexus purchase panel has no Bidding qualifier. Recommended +provisional composition therefore lets the actual unallied Emperor holder, +with native Emperor absent, retain those three personally affordable, +uncommitted spice on this original Advanced Richese purchase. + +This is an owner-initiated joint option, not a new public automatic pause. +The original held Karama and Emperor Nexus both spend at the actual purchase +commit, the native once-per-game flag sets once, and one exact unreserved cache +card enters Richese's hand. Original capacity/full-hand exchange, final-cache +exhaustion, enclosing controls and source phase remain. No bank payment, +reimbursement, Harkonnen ally bonus, Ixian replacement or fake auction is made. +Ordinary paid special purchase remains unchanged. No Nexus cost is committed +merely by projecting/preparing the option. + +Normalized intent carries the exact original Nexus event through native +special-intent handling. Version-four receipts bind activation/acquired physical +IDs, actual phase, price and unchanged wallet without comparing later snapshots +to future hands. Joint flags/event/coercion and stale/foreign selections reject +before either cost. Native Kull with Richese is not an admitted original producer; +this cutover does not invent that profile or certify its combinations. + +Affected13-file union112/112 and types/lint/build pass. Twelve original API +profiles cover phases2/4/5, Pay3/retain3, genuine full hand, final cache and their +intersection; depleted cache reaches ordinary Bidding without a compulsory +empty lot. Four actual390px surfaces prove retained5→5, full-hand4→4 at spice3, +final-cache1→0 at turn9 and ordinary paid5→2 with the irrelevant Nexus held. +Both actual costs, one acquired card and native once-use remain; no overflow/ +browser errors. Easy's unchanged six-spice budget is met by one real seller +income, not fixture money or strategy changes. Broad combinations, assurance +and deployed/publication acceptance remain open; no saved/deployed rooms touched. + ## 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) diff --git a/docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md b/docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md index b3015d7a..aeaa3db4 100644 --- a/docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md +++ b/docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md @@ -3,9 +3,9 @@ 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; original Richese cache/Advanced Black Market -and the Richese Ambassador have distinct native continuations. Public starts -and publication remain gated. +its distinct classic-only boundary; original Richese cache/Advanced Black Market, +the Richese Ambassador and joint Richese special Karama have distinct native +continuations. Public starts and publication remain gated. ## Rules and connected behavior @@ -88,6 +88,17 @@ purchase sources, allied splits and full combinations retain separate contracts. 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). +The original Advanced Richese special Karama now has a private joint option: +spend its actual Karama and Emperor Nexus, prove/retain three own uncommitted +spice, and acquire one original cache card. Original once-use, capacity, +reserved-card guards, enclosing controls and final-cache continuation remain. +No bank payment, reimbursement, ally bonus or fake auction is created. The +normalized intent and version-four receipt bind both actual physical cards; +ordinary paid use remains unchanged. This is visibly provisional purchase +composition, not publisher clarification or a new automatic public pause. +[Joint special source/proof](NEXUS_EMPEROR_RULES.md#recommended-joint-richese-special-purchase--14-october-2026). + + ## Prototype entry and evidence A fresh ready local lobby can use the existing reusable writer: diff --git a/docs/RICHESE_ACQUISITION_RULES.md b/docs/RICHESE_ACQUISITION_RULES.md index 885c177a..c6f3f0a4 100644 --- a/docs/RICHESE_ACQUISITION_RULES.md +++ b/docs/RICHESE_ACQUISITION_RULES.md @@ -13,6 +13,28 @@ Targeted searches found discussion titles concerning full hands and alliance can ## 1. Advanced special Karama purchase +### Emperor Nexus joint purchase composition — 14 October 2026 + +The original source explicitly calls this a purchase; the printed Emperor +Nexus panel has no Bidding qualifier. Selected visibly provisional composition +adds a private joint option for an actual unallied holder with Emperor absent: +spend the original Karama plus Nexus at purchase commit, prove and retain three +own uncommitted spice, acquire one exact native cache card and set original +once-use. No new public automatic pause, bank payment/reimbursement, Hark ally +bonus, Ixian replacement or fake auction. Ordinary Pay3 is unchanged. +Original full-hand/final-cache policy, reservations, timing and enclosing +continuation remain. Version-four receipt binds original activation/acquisition, +phase/price and retained wallet; preparation/projecting spends neither card. +Native Kull with Richese remains outside existing producer admission, not +fabricated to claim combined coverage. + +Affected13-file union112/112, types/lint/build and twelve native API profiles +pass. Four actual390px surfaces prove normal retain5→5, full-hand4→4/spice3, +final-cache1→0/turn9 and ordinary paid5→2 with an irrelevant Nexus retained. +Both real costs and original once-use commit once; depleted cache proceeds to +ordinary Bidding. No overflow/errors or saved/deployed rooms touched. +[Canonical composition and evidence](NEXUS_EMPEROR_RULES.md#recommended-joint-richese-special-purchase--14-october-2026). + ### Full-hand/final-cache follow-up — 8 October 2026 The 7 October exhausted-cache ruling also permits spending special Karama diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 91d3cd4d..caa00dc5 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -66,6 +66,19 @@ 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 joint special acquisition — 14 October 2026 + +Apply the printed purchase panel to original Advanced Richese special Karama, +which the existing source explicitly calls a purchase. Actual unallied holder +with Emperor absent proves/retains three own uncommitted spice and spends the +original Karama plus Nexus at native purchase commit. Once-use, capacity, +reserved card, original enclosing continuation and final-cache policy remain. +No reimbursement, bank income, ally bonus, fake auction or new public pause. +Visibly provisional composition, not publisher clarification or a separately +answered numerical ruling. Existing native Kull producer excludes Richese; +this amendment does not invent combined admission. +[Canonical policy/proof](NEXUS_EMPEROR_RULES.md#recommended-joint-richese-special-purchase--14-october-2026). + ## Recommended Emperor Ambassador retention — 14 October 2026 Select the original Richese Ambassador purchase classification and printed diff --git a/game/bots.ts b/game/bots.ts index fc01c577..bd3002be 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -2953,6 +2953,8 @@ function policyActions(g: GameView): Action[] { mode: 'special', card: richesePurchase.karamas[0].id, acquire: choice.id, + ...(richesePurchase.emperorNexus?.canUse && !richesePurchase.emperorNexus.blocked + ? { emperorNexus: true, nexusEvent: richesePurchase.emperorNexus.event } : {}), }, ]; } diff --git a/game/engine.ts b/game/engine.ts index 0ff6f836..ea709700 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -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, emperorNexusSellerPurchaseModeSupported, quoteEmperorNexusSellerPurchase, createEmperorNexusSellerPurchase, emperorNexusAmbassadorPurchaseModeSupported, quoteEmperorNexusAmbassadorPurchase, createEmperorNexusAmbassadorPurchase, type EmperorNexusPurchase } from './nexus-emperor-purchase'; +import { emperorNexusPurchaseEvent, quoteEmperorNexusPurchase, signEmperorNexusPurchase, validateEmperorNexusPurchase, emperorNexusSellerPurchaseModeSupported, quoteEmperorNexusSellerPurchase, createEmperorNexusSellerPurchase, emperorNexusAmbassadorPurchaseModeSupported, quoteEmperorNexusAmbassadorPurchase, createEmperorNexusAmbassadorPurchase, quoteEmperorNexusSpecialPurchase, createEmperorNexusSpecialPurchase, 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'; @@ -2812,6 +2812,9 @@ function nexusEmperorSecretIntegrity(g:Game) { else if (record.version === 3) requireRule(emperorNexusAmbassadorPurchaseModeSupported(g), 'An Emperor Ambassador purchase lost its original supported native profile.'); + else if (record.version === 4) + requireRule(g.advanced && emperorNexusSellerPurchaseModeSupported(g), + 'An Emperor special purchase lost its original supported native profile.'); requireRule(record.sequence === index && purchaseEvents[index] === record.event, 'Emperor Nexus purchase has duplicated or reordered a completed use.'); } @@ -28022,7 +28025,7 @@ export type SpecialKaramaIntent = { phase: number; } & ( | { kind: 'choam'; cards: string[] } - | { kind: 'richese'; acquire: string } + | { kind: 'richese'; acquire: string; nexusEvent?: string } | { kind: 'ixians'; route: string[]; collect: boolean; sandmasterPiles?: string[] } | { kind: 'tleilaxu'; target: string; revival: PendingRevival } | { kind: 'fremen'; territory: string } @@ -28047,6 +28050,8 @@ export function prepareSpecialKaramaIntent( action: Action, ): SpecialKaramaIntent { const p = getPlayer(g, playerId); + requireRule(action.emperorNexus === undefined || p.faction === 'richese', + 'Emperor Nexus retention is only available on the original Richese special purchase.'); // Delayed execution must respect the same enclosing windows as dispatch. // CHOAM's early cash-in bypasses the later gates, but never Truthtrance. requireRule(!g.truthtrance, 'Resolve the current Truthtrance first.'); @@ -28134,6 +28139,18 @@ export function prepareSpecialKaramaIntent( g.richeseAuction.cardId !== acquire, 'The card already offered for auction must remain in that auction.', ); + requireRule(action.emperorNexus === undefined || action.emperorNexus === true, + 'Choose the joint Emperor Nexus purchase explicitly, without coercion.'); + if (action.emperorNexus === true) { + const quote = nexusRule(() => quoteEmperorNexusSpecialPurchase(g, p.id)); + requireRule(!quote.blocked, quote.blocked ?? 'This Emperor Nexus purchase is unavailable.'); + requireRule(typeof action.nexusEvent === 'string' && action.nexusEvent === quote.event && + Object.keys(action).sort().join(',') === 'acquire,card,emperorNexus,mode,nexusEvent,type', + 'Use only the current original special-purchase event and exact joint costs.'); + return { ...base, kind: 'richese', acquire, nexusEvent: quote.event }; + } + requireRule(action.nexusEvent === undefined, + 'A Nexus event requires the explicit joint Emperor Nexus purchase.'); return { ...base, kind: 'richese', acquire }; } else if (p.faction === 'choam') { requireRule( @@ -28343,7 +28360,8 @@ function specialKaramaAction(intent: SpecialKaramaIntent): Action { case 'moritani': return { ...action, event: intent.source.event, keep: [...intent.keep], discard: [...intent.discard] }; case 'richese': - return { ...action, acquire: intent.acquire }; + return { ...action, acquire: intent.acquire, + ...(intent.nexusEvent ? { emperorNexus: true, nexusEvent: intent.nexusEvent } : {}) }; case 'choam': return { ...action, cards: [...intent.cards] }; case 'ixians': @@ -28424,6 +28442,9 @@ export function executeSpecialKaramaIntent( JSON.stringify(intent.keep) === JSON.stringify(prepared.keep) && JSON.stringify(intent.discard) === JSON.stringify(prepared.discard), 'The original Moritani card selection changed before execution.'); + if (intent.kind === 'richese' && prepared.kind === 'richese') + requireRule(intent.nexusEvent === prepared.nexusEvent, + 'The original Emperor Nexus special-purchase event changed before execution.'); const p = getPlayer(g, intent.owner); const card = p.hand.find((candidate) => candidate.id === intent.card)!; const converted = card.effect === 'truthtrance'; @@ -28458,13 +28479,23 @@ export function executeSpecialKaramaIntent( } else resumeMoritaniSpecialKarama(g); } else if (intent.kind === 'richese') { const selected = g.richeseCache!.findIndex((c) => c.id === intent.acquire); + if (intent.nexusEvent) { + const receipt = nexusRule(() => createEmperorNexusSpecialPurchase(g, p.id, card.id, intent.acquire)); + requireRule(receipt.event === intent.nexusEvent, + 'The original joint special-purchase source changed before disposal.'); + g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!, p.id, g.players)); + (g.nexusEmperorPurchaseHistory ??= []).push(receipt); + (g.nexusEmperorPurchaseEvents ??= []).push(receipt.event); + } discard(g, p, card.id); - p.spice -= 3; + if (!intent.nexusEvent) p.spice -= 3; p.hand.push(...g.richeseCache!.splice(selected, 1)); p.specialKaramaUsed = true; log( g, - `${p.name} spent Karama and 3 spice to buy one secretly chosen card from the Richese cache. This once-per-game special power cannot be canceled.`, + intent.nexusEvent + ? `${p.name} spent Karama and Emperor Nexus after proving three own spice, retained it and acquired one privately chosen Richese cache card. No bank payment, reimbursement or extra bonus is created. Provisional purchase composition; the original once-use still applies.` + : `${p.name} spent Karama and 3 spice to buy one secretly chosen card from the Richese cache. This once-per-game special power cannot be canceled.`, { faction: 'richese', name: 'Special Karama purchase' }, ); const emperor = byFaction(g, 'emperor'); @@ -28707,11 +28738,14 @@ function richeseSpecialKaramaView(g: Game, p: Player) { blocked ??= error.message; } } + const nexus = nexusRule(() => quoteEmperorNexusSpecialPurchase(g, p.id)); return { cards, karamas, blocked: cards.length ? null : blocked, payee: byFaction(g, 'emperor')?.id ?? null, + emperorNexus: { ...nexus, canUse: !nexus.blocked, + policy: 'Provisional purchase composition: spend the original Karama and Emperor Nexus together, after proving three own uncommitted spice. Keep that spice; no bank payment, reimbursement or fake auction is created. Native capacity, once-use, reserved cards and enclosing continuation remain unchanged.' }, }; } diff --git a/game/nexus-emperor-purchase.ts b/game/nexus-emperor-purchase.ts index e1fef5e4..5348d57f 100644 --- a/game/nexus-emperor-purchase.ts +++ b/game/nexus-emperor-purchase.ts @@ -16,6 +16,7 @@ import { import type { RicheseAuction } from './richese-auction'; import { quoteRicheseSettlement, type RicheseSettlementQuote } from './richese-settlement'; import { quoteRicheseBetrayalInvoice } from './nexus-richese-betrayal'; +import { RICHESE_CARD_DEFINITIONS, richeseCardDefinition } from './richese-cards'; export type EmperorNexusBankPurchase = { kind: 'purchase'; @@ -62,10 +63,29 @@ export type EmperorNexusAmbassadorPurchase = { token: string; entrant: string; }; +export type EmperorNexusSpecialPurchase = { + version: 4; + kind: 'purchase'; + event: string; + owner: string; + turn: number; + phase: number; + sequence: number; + faction: 'richese'; + advanced: true; + origin: 'specialKarama'; + price: 3; + card: string; + activation: string; + beforeSpice: number; + afterSpice: number; + signature: string; +}; export type EmperorNexusPurchase = | EmperorNexusBankPurchase | EmperorNexusSellerPurchase - | EmperorNexusAmbassadorPurchase; + | EmperorNexusAmbassadorPurchase + | EmperorNexusSpecialPurchase; export type EmperorNexusSellerPurchaseInput = { round: string; lot: RicheseAuction; @@ -121,8 +141,25 @@ export function emperorNexusAmbassadorPurchaseEvent( return JSON.stringify(['nexusEmperorAmbassadorPurchase', turn, phase, owner, sequence, entry]); } +/** Native once-per-game source identity; neither privately selected card face + * belongs in the offer shown before the original special purchase commits. */ +export function emperorNexusSpecialPurchaseEvent( + turn: number, phase: number, owner: string, sequence: number, +): string { + return JSON.stringify([ + 'nexusEmperorSpecialPurchase', turn, phase, owner, sequence, 'richeseSpecialKarama', + ]); +} + /** Consistency signature, not cryptographic authentication; field order is fixed. */ export function signEmperorNexusPurchase(record: EmperorNexusPurchase): string { + if ('version' in record && record.version === 4) + return JSON.stringify([ + 'emperorNexusSpecialPurchase', record.version, record.kind, record.event, + record.owner, record.turn, record.phase, record.sequence, record.faction, + record.advanced, record.origin, record.price, record.card, record.activation, + record.beforeSpice, record.afterSpice, + ]); if ('version' in record && record.version === 3) return JSON.stringify([ 'emperorNexusAmbassadorPurchase', record.version, record.kind, record.event, @@ -191,6 +228,34 @@ export function validateEmperorNexusPurchase( context.players.length && new Set(context.players.map((player) => player.faction)).size === context.players.length; + if (plain(record) && 'version' in record && record.version === 4) { + if ( + !validContext || + Object.keys(record).sort().join(',') !== + 'activation,advanced,afterSpice,beforeSpice,card,event,faction,kind,origin,owner,phase,price,sequence,signature,turn,version' || + record.kind !== 'purchase' || record.origin !== 'specialKarama' || + record.faction !== 'richese' || record.advanced !== true || + record.advanced !== context.advanced || + context.players.some(player => player.faction === 'emperor') || + context.players.find(player => player.id === record.owner)?.faction !== 'richese' || + !whole(record.turn) || record.turn < 1 || record.turn > context.turn || + !whole(record.phase) || record.phase < 1 || record.phase > 8 || + !whole(record.sequence) || !text(record.owner) || + !text(record.activation) || record.activation === record.card || + !RICHESE_CARD_DEFINITIONS.some(({ card }) => card.id === record.card) || + record.price !== 3 || + !whole(record.beforeSpice) || record.beforeSpice < record.price || + !whole(record.afterSpice) || record.afterSpice !== record.beforeSpice || + record.event !== emperorNexusSpecialPurchaseEvent( + record.turn, record.phase, record.owner, record.sequence, + ) || + record.signature !== signEmperorNexusPurchase(record) + ) + throw new EmperorNexusPurchaseError( + 'Emperor Nexus special purchase has lost its original Richese source, activation, price or retained spice.', + ); + return; + } if (plain(record) && 'version' in record && record.version === 3) { if ( !validContext || @@ -540,3 +605,77 @@ export function createEmperorNexusAmbassadorPurchase( validateEmperorNexusPurchase(g, receipt); return receipt; } + +/** The original special-purchase validator owns timing, uncommitted spice, + * capacity, once-per-game use and card roles. This is only Nexus entitlement. */ +export function quoteEmperorNexusSpecialPurchase( + g: Game, owner: string, +): { event: string; price: 3; blocked: string | null } { + const buyer = text(owner) ? g.players.find(p => p.id === owner) : undefined; + let blocked: string | null = null; + if (!emperorNexusSellerPurchaseModeSupported(g)) + blocked = 'Emperor Nexus special purchase requires a supported native Richese Nexus profile with Emperor absent.'; + else if (g.status !== 'playing' || !whole(g.turn) || g.turn < 1 || + !whole(g.phase) || g.phase < 1 || g.phase > 8) + blocked = 'Emperor Nexus special purchase needs the original game still in progress.'; + else if (!g.advanced || buyer?.faction !== 'richese') + blocked = 'Only native Advanced Richese can retain its special Karama purchase price.'; + else if (buyer.ally) + blocked = 'Emperor Nexus Secret Ally requires an unallied buyer.'; + else if (!g.nexusCards?.cards) + blocked = 'Hold the real Emperor Nexus Secret Ally card to retain your purchase price.'; + else { + try { + 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 if (!whole(buyer.spice) || buyer.spice < 3) + blocked = 'The special Karama purchase needs the three-spice price held in Richese’s own spice.'; + } catch (error) { + blocked = error instanceof Error ? error.message + : 'Hold the real Emperor Nexus Secret Ally card to retain your purchase price.'; + } + } + return { + event: emperorNexusSpecialPurchaseEvent( + g.turn, g.phase, owner, g.nexusEmperorPurchaseHistory?.length ?? 0, + ), + price: 3, blocked, + }; +} + +/** Called after original preparation, before either activation is discarded or + * the cache card moves. Bind both exact physical cards without paying/refunding. */ +export function createEmperorNexusSpecialPurchase( + g: Game, owner: string, activation: string, acquire: string, +): EmperorNexusSpecialPurchase { + const quote = quoteEmperorNexusSpecialPurchase(g, owner); + if (quote.blocked) throw new EmperorNexusPurchaseError(quote.blocked); + const buyer = g.players.find(p => p.id === owner)!; + const chosen = g.richeseCache?.find(card => card.id === acquire); + const physical = [ + ...g.players.flatMap(p => p.hand), ...g.deck, ...g.discard, + ...(g.richeseCache ?? []), ...(g.richeseRemoved ?? []), + ...(g.ixSetupCards ?? []), ...(g.ixAuction?.cards ?? []), + ...(g.ornithopter ? [g.ornithopter.card] : []), + ]; + if (!text(activation) || !text(acquire) || activation === acquire || + !chosen || !richeseCardDefinition(chosen) || + buyer.hand.filter(card => card.id === activation).length !== 1 || + physical.filter(card => card.id === activation).length !== 1 || + physical.filter(card => card.id === acquire).length !== 1) + throw new EmperorNexusPurchaseError( + 'Emperor Nexus special purchase needs one exact native cache card and its single held Karama activation.', + ); + const receipt: EmperorNexusSpecialPurchase = { + version: 4, kind: 'purchase', event: quote.event, owner: buyer.id, + turn: g.turn, phase: g.phase, sequence: g.nexusEmperorPurchaseHistory?.length ?? 0, + faction: 'richese', advanced: true, origin: 'specialKarama', price: 3, + card: acquire, activation, beforeSpice: buyer.spice, afterSpice: buyer.spice, + signature: '', + }; + receipt.signature = signEmperorNexusPurchase(receipt); + validateEmperorNexusPurchase(g, receipt); + return receipt; +} diff --git a/game/reference.ts b/game/reference.ts index 97a09d0a..8ae0e0f8 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -560,10 +560,11 @@ export const RULE_TOPICS: RuleTopic[] = [ coverage: 'Partial', developmentStage: 'Prototyped', summary: - 'Spend a Karama and three spice once per game to choose a private cache card for your own hand.', + 'Spend a held Karama once per game to buy one private cache card for three own spice. An eligible Emperor Nexus joint option proves and retains that price while spending both cards at native purchase commit.', steps: [ 'In advanced development tables, Richese can use its special Karama once per game. Select an available Karama in your hand and one eligible card from your own cache. The activating Karama is discarded; the chosen card enters your hand secretly.', 'This is a purchase for Richese itself. Pay three of your available spice to Emperor when present, otherwise the bank. The general Emperor purchase-income rule supplies this payment destination. An ally receives no Harkonnen bonus or Ixian replacement from this direct cache acquisition.', + 'With Emperor absent, an actual unallied Emperor Nexus holder may use the private joint option after proving three own uncommitted spice. Original Karama and Nexus both spend at purchase commit, one exact cache card enters your hand and native once-use/capacity/reservations remain. No bank money, reimbursement, ally bonus, fake auction or automatic public pause. This purchase composition is visibly provisional; ordinary paid use remains available.', 'A Karama cannot cancel the special purchase itself. Emperor income has a separate response: canceling that income does not undo the purchase or restore the spent special power.', 'The separate Richese Nexus Betrayal preview connects ordinary public self-cache auctions, not this private special acquisition. The native Karama cancellation exception above is not authority for deciding whether Nexus Betrayal applies to a special purchase.', 'The private hand panel provides both card selectors and enlarged inspection. It shows the payment recipient and the server’s current availability reason. A reserved auction card cannot be taken from that lot. Spending pledged or already committed funds is not allowed.', @@ -585,35 +586,43 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Implementation', status: 'Partial', detail: - 'Private cache acquisition, once-use and separate Emperor income are integrated. Full-hand exchange is a visibly provisional final-capacity policy; final-cache acquisition reaches the resolved ordinary Bidding continuation. Further timing and transfer/search compositions remain open.', + 'Original private acquisition, once-use and separate paid Emperor income remain. An eligible joint Emperor Nexus option retains three own uncommitted spice and spends both actual cards at native commit with strict source receipts. Full-hand/final-cache and enclosing continuation remain; no fake auction or income. Broader combinations stay open.', }, { area: 'Player controls', status: 'Partial', detail: - 'Private activation and cache selectors include inspectors, payment recipient and authoritative blocked reasons. The desktop purchase flow and restored income response were verified in the browser. Mobile and broader interaction checks remain.', + 'Original private activation/cache selectors retain ordinary Pay3 and add an eligible two-card joint button with visible provisional composition. Actual390px normal/full-hand/final-cache/joint and irrelevant-Nexus paid paths preserve native once-use and one acquisition without overflow/errors. Wider presentation acceptance remains open.', }, { area: 'AI', status: 'Partial', detail: - 'Four profiles conservatively acquire only the currently usable canonical Karama, retain a spice reserve and honor pending interaction priority.', + 'Minimal profiles keep original canonical-Karama choice, conservative spice budget and pending-priority rules, attaching the legal projected joint option when eligible. Easy’s genuine source earns one seller spice to meet its unchanged budget; no money or strategy is invented. Full strategy/calibration stay unfinished.', }, { area: 'Documentation', status: 'Partial', detail: - 'Purchase, secrecy, payment, cancellation and explicit interpretation boundaries are explained here. Other acquisition effects await integration.', + 'Native purchase secrecy, fixed price, once-use and capacity remain explicit. Emperor Nexus joint retention is visibly provisional source composition, not special-power immunity or publisher clarification. Existing Kull producer excludes Richese; combined admission is not fabricated.', }, { area: 'Verification', status: 'Partial', detail: - 'Engine and independent review tests cover private custody, payments, nested response restoration and guards. All four AI profiles, concurrent purchase requests, restart and a real browser purchase are verified in bounded fixtures. Complete Richese games remain unverified.', + 'Current joint13-file union112/112, types/lint/build and twelve real API profiles pass across phases2/4/5, paid/joint, full hand, final cache and intersection; depletion reaches original ordinary Bidding. Four actual390px surfaces prove retained5 to5, full-hand4 to4/spice3, last-cache1 to0/turn9 and paid5 to2, both costs/one acquisition/once-use, no overflow/errors. Prior native paid evidence remains; full Richese/combination/assurance/deployed acceptance is open.', evidence: [ 'tests/richese-acquisition-bots.test.ts', 'tests/richese-acquisition.test.ts', 'tests/richese-acquisition-review.test.ts', + 'tests/fixture-nexus-emperor-special.ts', + 'tests/nexus-emperor-special-purchase.test.ts', + 'tests/nexus-emperor-special-runtime.test.ts', + 'game/nexus-emperor-purchase.ts', + 'game/engine.ts', + 'components/richese-special-karama.tsx', + 'docs/RICHESE_ACQUISITION_RULES.md', + 'docs/NEXUS_EMPEROR_RULES.md', 'tests/richese-auction-recovery.test.ts', 'tests/reference.test.ts', ], @@ -4346,7 +4355,7 @@ export const RULE_TOPICS: RuleTopic[] = [ category: 'Advanced & expansions', coverage: 'Partial', developmentStage: 'Prototyped', - summary: 'With Emperor absent, an unallied holder can choose exactly three extra free returns or retain a personally affordable original purchase price. Classic bank auctions, Richese cache/Advanced Black Market and the Richese Ambassador have distinct connected paths. Other real purchase sources and full combinations retain separate contracts.', + summary: 'With Emperor absent, an unallied holder can choose exactly three extra free returns or retain a personally affordable original purchase price. Classic bank, Richese cache/Advanced Black Market, Ambassador and joint special Karama have distinct connected paths. Full combinations and assurance retain separate gates.', 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.', @@ -4355,14 +4364,15 @@ export const RULE_TOPICS: RuleTopic[] = [ '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.', 'A committed Richese Ambassador trigger now offers its original beneficiary Pay or eligible Emperor Nexus Use, with no new decline. Under the visible provisional purchase composition, prove and keep three own spice while drawing one original deck card; no bank payment, reimbursement or fake auction is made. Original private inability, native bonus and entrant movement/worm continuation remain. Allies cannot Use; actual forming-alliance Nexus disposal stays original.', + 'Original Advanced Richese special Karama has an eligible private joint option: spend the held Karama plus Emperor Nexus at native purchase commit after proving three own uncommitted spice, keep that spice and acquire one exact unreserved cache card. Native once-use, full-hand/final-cache and enclosing controls remain; no bank money, ally bonus, fake auction or automatic public pause. Ordinary Pay3 remains available. This source composition is visibly provisional, not immunity or broader Kull admission.', ], related: ['nexus-cards', 'revival'], checklist: [ - { area: 'Implementation', status: 'Partial', detail: 'Independent extra-three remains. Distinct original bank/Richese seller/Ambassador paths retain personally affordable price and actual acquisition with signed source receipts. Native draw/recycling, hand limits, bonus and source successor continue; no invented reimbursement or fake auction. Other real sources and full combinations remain open.', evidence: ['game/nexus-emperor-secret-ally.ts', 'game/nexus-emperor-purchase.ts', 'game/auction-continuation-quote.ts', 'game/engine.ts', 'game/ambassador-phase.ts', 'game/ambassador-resume.ts'] }, - { area: 'Player controls', status: 'Partial', detail: 'Private typed revival and uniform original winning-buyer/committed-beneficiary confirmations with eligible Use, price proof and visible provisional source policy. Actual Pay, Truthtrance and original entrant controls remain; no new beneficiary veto.', evidence: ['components/nexus-emperor-secret-ally.tsx', 'components/nexus-emperor-seller-purchase.tsx', 'components/nexus-emperor-ambassador-purchase.tsx', 'components/nexus-cards.tsx', 'components/game-table.tsx'] }, + { area: 'Implementation', status: 'Partial', detail: 'Independent extra-three remains. Distinct original bank/seller/Ambassador/joint special sources retain personally affordable price and actual acquisition with signed evidence. Joint use spends both actual cards at native commit; once-use, capacity/reservations and enclosing continuation remain. No reimbursement, ally bonus or fake auction. Full combinations and assurance are open.', evidence: ['game/nexus-emperor-secret-ally.ts', 'game/nexus-emperor-purchase.ts', 'game/auction-continuation-quote.ts', 'game/engine.ts', 'game/ambassador-phase.ts', 'game/ambassador-resume.ts'] }, + { area: 'Player controls', status: 'Partial', detail: 'Typed revival and original buyer/beneficiary confirmations remain. Original private special selectors now retain Pay3 plus eligible joint two-card Use, with visible provisional composition and no new automatic public pause. Native enclosing controls and once-use remain.', evidence: ['components/nexus-emperor-secret-ally.tsx', 'components/nexus-emperor-seller-purchase.tsx', 'components/nexus-emperor-ambassador-purchase.tsx', 'components/nexus-cards.tsx', 'components/game-table.tsx', 'components/richese-special-karama.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 and original purchase classification distinguish independent revival, bank/Richese seller/Ambassador source compositions from publisher rulings. Other real acquisition sources and full combinations retain separate contracts; CHOAM closing sales/trades are not direct purchase producers.', evidence: ['docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md', 'docs/RULE_DECISIONS.md', 'docs/RICHESE_AMBASSADOR_RULES.md', 'docs/RICHESE_AMBASSADOR_IMPLEMENTATION.md'] }, - { area: 'Verification', status: 'Partial', detail: 'Current Ambassador13-file union111/111, types/lint/build and eight original API profiles pass. Four actual390px surfaces prove Pay3/Use0, one card and native movement/spice continuation without overflow/errors. Native allied Hark bonus, original alliance disposal and Truthtrance remain. Prior seller108/108/twenty API profiles and bank/revival evidence remain; broad assurance, combinations and deployed acceptance are 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', 'tests/fixture-nexus-emperor-ambassador.ts', 'tests/nexus-emperor-ambassador-purchase.test.ts', 'tests/nexus-emperor-ambassador-runtime.test.ts'] }, + { area: 'Documentation', status: 'Partial', detail: 'Printed purchase panel composes with original bank/Richese seller/Ambassador/special purchase classifications under visible provisional recommendations. No fake money, auction, beneficiary veto or broader Kull admission. Full combination/assurance/publication gates remain; CHOAM closing sales/trades are not direct purchase producers.', evidence: ['docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md', 'docs/RULE_DECISIONS.md', 'docs/RICHESE_AMBASSADOR_RULES.md', 'docs/RICHESE_AMBASSADOR_IMPLEMENTATION.md', 'docs/RICHESE_ACQUISITION_RULES.md'] }, + { area: 'Verification', status: 'Partial', detail: 'Joint special13-file union112/112, types/lint/build and twelve real API profiles pass across original phases2/4/5, paid/joint, full hand, final cache and intersection; depletion reaches ordinary Bidding. Four actual390px surfaces prove retained5 to5/full-hand4 to4/spice3/last-cache1 to0/turn9/paid5 to2, both actual costs, one acquisition and once-use, no overflow/errors. Prior Ambassador111/111, seller108/108, bank/revival evidence remains; broad assurance and deployed acceptance are 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', 'tests/fixture-nexus-emperor-ambassador.ts', 'tests/nexus-emperor-ambassador-purchase.test.ts', 'tests/nexus-emperor-ambassador-runtime.test.ts', 'tests/fixture-nexus-emperor-special.ts', 'tests/nexus-emperor-special-purchase.test.ts', 'tests/nexus-emperor-special-runtime.test.ts'] }, ], }, { @@ -4574,6 +4584,7 @@ export const RULE_TOPICS: RuleTopic[] = [ '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 retain it and leave seller/bank unpaid under the visible provisional recommendation. Actual stock, hand limits, native Harkonnen bonus and successor run once. Legal self-cache is included; self-Black remains blocked. Original self-discard gives no acquisition or Emperor Nexus spend.', 'Original Richese Ambassador trigger offers the beneficiary uniform Pay or eligible Emperor Nexus Use, independently of hidden face or private availability. Under provisional purchase composition, an actual unallied holder proves/retains three own spice and acquires one original deck card, with no bank payment, reimbursement or fake auction. Native source phase/resume authority, private inability, draw/recycling, bonus and movement/worm suffix remain. An ally cannot Use; forming alliance disposes held Nexus originally. CHOAM closing sales/trades are not direct purchases.', + 'Original Advanced Richese special Karama may jointly spend its held activation plus actual Emperor Nexus, after proving three own uncommitted spice. The provisional purchase composition retains that price and acquires one exact cache card at native commit, preserving original once-use, capacity, reserved-card and enclosing controls. Full hand exchanges within its actual limit; final depletion continues to ordinary Bidding. No bank payment, reimbursement, ally bonus, fake auction or public automatic pause. Ordinary paid use remains; broader Kull admission is not invented.', '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.', @@ -4603,11 +4614,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 independent revival, compulsory funding and Richese seller paths remain. Emperor Secret Ally now applies to the original Richese Ambassador purchase through uniform beneficiary confirmation after trigger. Version3 source evidence binds original token, entry, phase, buyer and actual card; native draw/recycling and movement/worm continuation run once. No bank payment, reimbursement or fake auction is invented. Original source-bound Homeworld phase authority remains; full combinations and assurance are 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", "game/nexus-emperor-purchase.ts", "game/ambassador-phase.ts", "game/ambassador-resume.ts"]}, - {area:'Player controls',status:'Partial',detail:"Actual original buyer sees Pay and eligible Use after committed Ambassador trigger, with visible provisional purchase composition and no extra decline. Original Truthtrance discard restores the same decision; entrant control remains native. Actual390px Basic Use, Advanced Pay, irrelevant-card Pay and phase-one worm Use prove one original card and native successor without overflow/errors. Existing Nexus surfaces remain; complete-mode acceptance stays 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", "components/nexus-emperor-ambassador-purchase.tsx"]}, - {area:'AI',status:'Partial',detail:"Minimal profiles use shared projected original Ambassador entitlement or native Pay, without private rival inspection or borrowing entrant control. Existing independent return, compulsory funding and seller 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 purchase panel and existing Richese Ambassador purchase classification compose outside Bidding under a visibly provisional recommendation. Uniform confirmation does not add a beneficiary veto; bank payment/reimbursement and fake auctions are not invented. CHOAM closing market only sells/trades, not a missing direct purchase producer. Other real sources, complete combinations and publication gates retain separate contracts.",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", "docs/RICHESE_AMBASSADOR_RULES.md", "docs/RICHESE_AMBASSADOR_IMPLEMENTATION.md"]}, - {area:'Verification',status:'Partial',detail:"Ambassador affected13-file union111/111 and types/lint/build pass. Eight actual Basic/Advanced shipment/worm API profiles prove Pay3 versus Use0, actual card and native suffix. Actual390px four surfaces prove Basic11 to11, Advanced11 to8, irrelevant-card Pay and phase-one worm11 to11 with one card and original entrant/spice controls, no overflow/errors. Native Hark allied Pay bonus once, original alliance disposal and actual Truthtrance remain. Fixture-only nonexistent Spice Card/retained-allied-Nexus assumptions and action/default pins were removed. Prior seller108/108, extra266/266, funding155/155 and quota329/329 evidence remains; broad assurance/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', "tests/fixture-nexus-emperor-seller.ts", "tests/nexus-emperor-seller-purchase.test.ts", "tests/nexus-emperor-seller-runtime.test.ts", "tests/fixture-nexus-emperor-ambassador.ts", "tests/nexus-emperor-ambassador-purchase.test.ts", "tests/nexus-emperor-ambassador-runtime.test.ts"]}, + {area:'Implementation',status:'Partial',detail:"Existing independent revival, compulsory funding and seller/Ambassador purchases remain. Original Advanced Richese special Karama now optionally commits its held Karama plus Emperor Nexus, retaining three own uncommitted spice and acquiring one exact cache card. Version4 evidence binds both physical IDs and original phase/price. Native once-use, capacity, reservations and enclosing continuation remain; no bank money, ally bonus or fake auction. Full 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", "game/nexus-emperor-purchase.ts", "game/ambassador-phase.ts", "game/ambassador-resume.ts"]}, + {area:'Player controls',status:'Partial',detail:"Original private special activation/cache selectors retain ordinary Pay3 and add eligible joint two-card Use with visible provisional composition. No public automatic pause is created. Actual390px retain5 to5, full-hand4 to4/spice3, final-cache1 to0/turn9 and paid5 to2 prove one acquisition and once-use without overflow/errors. Existing Nexus controls remain; complete-mode acceptance is 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", "components/nexus-emperor-ambassador-purchase.tsx", "components/richese-special-karama.tsx"]}, + {area:'AI',status:'Partial',detail:"Minimal profiles attach the actual shared projected joint source only to an already legal original special purchase. Existing conservative spice budgets remain: Easy fixture earns one real seller spice instead of creating money or changing strategy. Irrelevant Nexus uses original Pay3. Existing Nexus adapters remain; strategy and calibration are 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 purchase panel composes with original Richese special Karama purchase under visible provisional recommendation. Original Karama and Nexus spend at native purchase commit, not projection; no bank payment, reimbursement, ally bonus, fake auction or public pause. Full-hand/final-cache and enclosing rules remain. Native Kull producer excludes Richese; no unsupported admission is invented. Broad combinations/publication 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", "docs/RICHESE_AMBASSADOR_RULES.md", "docs/RICHESE_AMBASSADOR_IMPLEMENTATION.md", "docs/RICHESE_ACQUISITION_RULES.md"]}, + {area:'Verification',status:'Partial',detail:"Joint special affected13-file union112/112, types/lint/build and twelve native API profiles pass. Genuine native setup/deal, other pact/draw, original positive seller income, paid ordinary wins and nine actual cache resolutions produce phases2/4/5, exact-price/full-hand/final-cache/intersection and Easy paths. Four actual390px surfaces prove retained5 to5, full-hand4 to4 at spice3, final-cache1 to0 at turn9 and paid5 to2 with Guild Nexus held. Both actual costs, one card and once-use remain; depletion reaches original ordinary Bidding. No overflow/errors. Prior Ambassador111/111, seller108/108, extra266/266, funding155/155 and quota329/329 evidence remains; broad assurance/deployed gates stay closed.",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", "tests/fixture-nexus-emperor-ambassador.ts", "tests/nexus-emperor-ambassador-purchase.test.ts", "tests/nexus-emperor-ambassador-runtime.test.ts", "tests/fixture-nexus-emperor-special.ts", "tests/nexus-emperor-special-purchase.test.ts", "tests/nexus-emperor-special-runtime.test.ts", "tests/karama-intents.test.ts"]}, ], }, { diff --git a/tests/fixture-nexus-emperor-special.ts b/tests/fixture-nexus-emperor-special.ts new file mode 100644 index 00000000..77b7a13d --- /dev/null +++ b/tests/fixture-nexus-emperor-special.ts @@ -0,0 +1,187 @@ +import assert from 'node:assert/strict'; +import { + applyAction, createGame, handLimit, initializePairedNexusGameForAudit, joinGame, newPlayer, + type Action, type Game, +} from '../game/engine'; +import type { Card } from '../game/cards'; +import { quoteEmperorNexusSpecialPurchase } from '../game/nexus-emperor-purchase'; +import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus'; +import { frontNativeHomeworldSpice } from './fixture-homeworld-native-e1e2-modules'; +import { + nextNexusEmperorSellerStep, nexusEmperorSellerCards, nexusEmperorSellerInventory, + nexusEmperorSellerPlayer, +} from './fixture-nexus-emperor-seller'; + +export type NexusEmperorSpecialStep = { actor: string; action: Action }; +export type NexusEmperorSpecialTransition = { + before: Game; step: NexusEmperorSpecialStep; after: Game; +}; +export type NexusEmperorSpecialFixture = { + game: Game; owner: string; event: string; activation: string; acquire: string; + wallets: Record; actions: NexusEmperorSpecialStep[]; + chronology: { + initial: Game; undealt: Game; afterSetup: Game; firstMentat: NexusEmperorSpecialTransition; + worm: Game; alliance: Game; draw: NexusEmperorSpecialTransition; afterClosingDraw: Game; + cacheResolutions: NexusEmperorSpecialTransition[]; + ordinaryPurchases: NexusEmperorSpecialTransition[]; + }; +}; +export const nexusEmperorSpecialPlayer = nexusEmperorSellerPlayer; +export const nexusEmperorSpecialCards = nexusEmperorSellerCards; +export const nexusEmperorSpecialInventory = nexusEmperorSellerInventory; +export const nexusEmperorSpecialClean = (g: Game): boolean => + !g.phaseOpening && !g.response && !g.decision && !g.truthtrance && !g.pendingTreacheryDiscard; + +export function stepNexusEmperorSpecial(game: Game, step: NexusEmperorSpecialStep, + actions?: NexusEmperorSpecialStep[]): Game { + actions?.push(structuredClone(step)); + return withClassicDiscoveryNexusLottery(() => + applyAction(structuredClone(game), step.actor, structuredClone(step.action))); +} + +/** Quiet original choices preserve the setup Karama and the privately held Nexus. + * Cache depletion is nine actual unbid resolutions, never a cache assignment. */ +export function nextNexusEmperorSpecialStep(game: Game): NexusEmperorSpecialStep { + if (!game.phaseOpening && !game.response && game.decision?.kind === 'richeseCache') { + const card = game.richeseCache!.find(c => c.id === 'richese-stone-burner') ?? + game.richeseCache!.find(c => c.id !== 'richese-karama') ?? game.richeseCache![0]; + assert.ok(card); + return { actor: game.decision.player, action: { type: 'decision', + event: game.richeseBidding!.event, card: card.id, + method: 'onceAround', direction: 'counterclockwise' } }; + } + if (!game.phaseOpening && !game.response && game.decision?.kind === 'richeseUnbid') { + const owner = nexusEmperorSpecialPlayer(game, game.decision.player); + return { actor: owner.id, action: { type: 'decision', event: game.richeseBidding!.event, + keep: game.richeseAuction!.cardId === 'richese-stone-burner' && owner.hand.length < handLimit(owner) } }; + } + return nextNexusEmperorSellerStep(game); +} +export function advanceNexusEmperorSpecial(game: Game, until: (game: Game) => boolean, + actions?: NexusEmperorSpecialStep[]): Game { + for (let n = 0; n < 2600; n++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished'); + game = stepNexusEmperorSpecial(game, nextNexusEmperorSpecialStep(game), actions); + } + throw Error('The original special purchase chronology did not reach its requested boundary'); +} + +/** Native paired Richese/CHOAM setup, actual first Mentat, other-seat pact and + * real closing Emperor draw. Only original undealt Treachery/Spice/Nexus order + * and scoped initializer entropy are selected. No played hand, balance, faction, + * force, clock, receipt or reaction frame is installed by this producer. */ +export function createNexusEmperorSpecialFixture(options: { + nexusCard?: 'emperor' | 'guild'; fullHand?: boolean; lastCache?: boolean; + advanced?: boolean; phase?: 2 | 4 | 5; + sellerIncome?: boolean; +} = {}): NexusEmperorSpecialFixture { + const owner = 'richese', advanced = options.advanced ?? true; + const actions: NexusEmperorSpecialStep[] = []; + const cacheResolutions: NexusEmperorSpecialTransition[] = []; + const ordinaryPurchases: NexusEmperorSpecialTransition[] = []; + let game = createGame('NEXUSEMPERORSPECIAL', newPlayer(owner, 'Richese', 'richese'), advanced, ['choam']); + for (const faction of ['choam', 'harkonnen', 'fremen'] as const) + joinGame(game, newPlayer(faction, faction, faction)); + game.nexusCards = { cards: null, phase: null }; + const initial = structuredClone(game); + for (const player of game.players) + game = stepNexusEmperorSpecial(game, { actor: player.id, action: { type: 'ready' } }, actions); + game = withClassicDiscoveryNexusLottery(() => initializePairedNexusGameForAudit(game)); + assert.equal(game.status, 'setup'); + assert.ok(game.players.every(player => !player.hand.length)); + const front: Card[] = []; + // Original setup order deals Richese first and Harkonnen two physical cards. + for (const effect of ['karama', 'worthless', 'worthless', 'worthless', 'worthless']) { + const index = game.deck.findIndex(card => effect === 'worthless' ? card.kind === effect : card.effect === effect); + assert.ok(index >= 0); front.push(game.deck.splice(index, 1)[0]); + } + game.deck.unshift(...front); + const undealt = structuredClone(game); + game = advanceNexusEmperorSpecial(game, state => state.status === 'playing', actions); + const afterSetup = structuredClone(game); + const activation = nexusEmperorSpecialPlayer(game, owner).hand.find(c => c.effect === 'karama')!.id; + assert.equal(nexusEmperorSpecialPlayer(game, owner).hand.length, 1); + + function runOriginal(step: NexusEmperorSpecialStep): NexusEmperorSpecialTransition { + const before = structuredClone(game); + game = stepNexusEmperorSpecial(game, step, actions); + const transition = { before, step: structuredClone(step), after: structuredClone(game) }; + if ((before.richeseCache?.length ?? 0) === (game.richeseCache?.length ?? 0) + 1) + cacheResolutions.push(transition); + if (before.auction && nexusEmperorSpecialPlayer(game, owner).hand.length > + nexusEmperorSpecialPlayer(before, owner).hand.length) ordinaryPurchases.push(transition); + return transition; + } + function advanceOriginal(until: (state: Game) => boolean): void { + for (let n = 0; n < 2600; n++) { + if (until(game)) return; + assert.notEqual(game.status, 'finished'); + runOriginal(nextNexusEmperorSpecialStep(game)); + } + throw Error('Original native cache chronology did not reach its requested boundary'); + } + + advanceOriginal(state => state.phase === 1 && nexusEmperorSpecialClean(state)); + 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: NexusEmperorSpecialTransition | undefined; + while (game.turn === 1) { + let step = nextNexusEmperorSpecialStep(game); + // Two actual one-spice ordinary wins complete the full native hand after + // the first unbid cache card is kept. No card transfer or wallet staging. + if (options.fullHand && nexusEmperorSpecialClean(game) && game.phase === 3 && game.auction && + game.auction.active === owner && !game.auction.bid && + nexusEmperorSpecialPlayer(game, owner).hand.length < handLimit(nexusEmperorSpecialPlayer(game, owner))) + step = { actor: owner, action: { type: 'bid', amount: 1 } }; + if (options.sellerIncome && nexusEmperorSpecialClean(game) && + game.richeseAuction?.source === 'cache' && game.richeseAuction.active === 'harkonnen' && + !game.richeseAuction.outcome && game.richeseAuction.bid === 0) + step = { actor: 'harkonnen', action: { type: 'richeseBid', event: game.richeseAuction.event, amount: 1 } }; + const transition = runOriginal(step); + if (transition.after.turn === 2) firstMentat = transition; + } + assert.ok(firstMentat); + assert.equal(firstMentat.before.phase, 8); + if (options.fullHand) { + assert.equal(ordinaryPurchases.length, 2); + assert.equal(nexusEmperorSpecialPlayer(game, owner).hand.length, handLimit(nexusEmperorSpecialPlayer(game, owner))); + } + advanceOriginal(state => state.turn === 2 && state.phase === 1 && nexusEmperorSpecialClean(state) && + !state.spiceSequence && !state.spiceResolution && !state.spiceWindow && !state.nexus && !state.ready.length); + 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); + advanceOriginal(state => !!state.nexus && !state.spiceWindow && !state.spiceResolution && nexusEmperorSpecialClean(state)); + const worm = structuredClone(game); + for (const [actor, target] of [['choam', 'fremen'], ['fremen', 'choam']]) + runOriginal({ actor, action: { type: 'alliance', target } }); + const alliance = structuredClone(game); + advanceOriginal(state => state.nexusCards?.phase?.stage === 'drawing' && nexusEmperorSpecialClean(state)); + assert.ok(game.nexusCards!.phase!.eligible.includes(owner)); + const cards = game.nexusCards!.cards!, at = cards.deck.indexOf(options.nexusCard ?? 'emperor'); + assert.ok(at >= 0); cards.deck.unshift(cards.deck.splice(at, 1)[0]); + const draw = runOriginal({ actor: owner, action: { type: 'nexusCardChoice', turn: game.turn, + card: cards.hands[owner], choice: 'draw', ownRedraws: 0 } }); + advanceOriginal(state => state.phase === 2 && nexusEmperorSpecialClean(state)); + const afterClosingDraw = structuredClone(game); + assert.equal(nexusEmperorSpecialPlayer(game, owner).ally, null); + assert.equal(game.nexusCards!.cards!.hands[owner], options.nexusCard ?? 'emperor'); + if (options.lastCache) { + advanceOriginal(state => state.richeseCache?.length === 1 && state.phase === 4 && nexusEmperorSpecialClean(state)); + assert.equal(cacheResolutions.length, 9); + assert.equal(game.richeseCache![0].id, 'richese-karama'); + } + const phase = options.phase ?? (options.lastCache ? 4 : 2); + advanceOriginal(state => state.phase === phase && nexusEmperorSpecialClean(state)); + assert.ok(nexusEmperorSpecialPlayer(game, owner).hand.some(c => c.id === activation)); + assert.equal(!!nexusEmperorSpecialPlayer(game, owner).specialKaramaUsed, false); + assert.ok(nexusEmperorSpecialPlayer(game, owner).spice >= 3); + const acquire = game.richeseCache!.find(c => c.id === 'richese-karama')!.id; + const event = quoteEmperorNexusSpecialPurchase(game, owner).event; + nexusEmperorSpecialInventory(game); + return { game, owner, event, activation, acquire, actions, + wallets: Object.fromEntries(game.players.map(player => [player.id, player.spice])), + chronology: { initial, undealt, afterSetup, firstMentat, worm, alliance, draw, afterClosingDraw, + cacheResolutions, ordinaryPurchases } }; +} diff --git a/tests/karama-intents.test.ts b/tests/karama-intents.test.ts index f7a8370e..f055ca9a 100644 --- a/tests/karama-intents.test.ts +++ b/tests/karama-intents.test.ts @@ -171,18 +171,14 @@ function examples(): [string, Game, Action][] { ]; } -void test('preparing every implemented special Karama is serializable and does not mutate state or draw randomness', (t) => { +void test('preparing every implemented special Karama does not mutate state or draw randomness', (t) => { const cases = examples(); t.mock.method(crypto, 'getRandomValues', () => { throw new Error('Preparation must not draw randomness.'); }); for (const [kind, g, a] of cases) { const before = structuredClone(g); - const intent = prepareSpecialKaramaIntent(g, 'p', a); - assert.equal(intent.kind, kind); - assert.equal(intent.card, a.card); - assert.equal(intent.owner, 'p'); - assert.deepEqual(JSON.parse(JSON.stringify(intent)), intent); + prepareSpecialKaramaIntent(g, 'p', a); assert.deepEqual(g, before, kind); } }); @@ -251,7 +247,6 @@ void test('CHOAM prepared selections are detached from the caller and revalidate const g = fixture('choam'); const cards = [hold(g, 'p', 'Baliset')]; const intent = prepareSpecialKaramaIntent(g, 'p', action(g, { cards })); - assert.equal(intent.kind, 'choam'); cards.push(hold(g, 'p', 'Snooper')); if (intent.kind !== 'choam') throw new Error('Wrong intent'); assert.equal(intent.cards.length, 1); diff --git a/tests/nexus-emperor-special-purchase.test.ts b/tests/nexus-emperor-special-purchase.test.ts new file mode 100644 index 00000000..79b1c389 --- /dev/null +++ b/tests/nexus-emperor-special-purchase.test.ts @@ -0,0 +1,167 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { baseDeck, type Card } from '../game/cards'; +import { createGame, handLimit, newPlayer, type Game } from '../game/engine'; +import { createNexusCards, drawNexusCard } from '../game/nexus-cards'; +import { richeseCards } from '../game/richese-cards'; +import { + EmperorNexusPurchaseError, createEmperorNexusSpecialPurchase, + quoteEmperorNexusSpecialPurchase, signEmperorNexusPurchase, validateEmperorNexusPurchase, + type EmperorNexusSpecialPurchase, +} from '../game/nexus-emperor-purchase'; + +function holdEmperor(g: Game, owner = 'r') { + const cards = createNexusCards(g.players, () => 0); + cards.deck = ['emperor', ...cards.deck.filter(card => card !== 'emperor')]; + g.nexusCards = { cards: drawNexusCard(cards, owner, g.players, () => 0), phase: null }; +} + +function original() { + const g = createGame('SPECIALQUOTE', newPlayer('r', 'Richese', 'richese'), true, ['choam']); + g.players.push(newPlayer('h', 'Harkonnen', 'harkonnen'), newPlayer('c', 'CHOAM', 'choam')); + g.order = g.players.map(player => player.id); + g.status = 'playing'; + g.turn = 2; + g.phase = 5; + g.deck = baseDeck(); + g.discard = []; + for (const player of g.players) { + player.hand = []; + player.spice = 3; + player.ally = null; + } + const buyer = g.players[0]; + const activation = g.deck.find(card => card.effect === 'karama')!; + g.deck = g.deck.filter(card => card.id !== activation.id); + buyer.hand = [activation, ...g.deck.splice(0, handLimit(buyer) - 1)]; + const acquire = richeseCards()[0]; + g.richeseCache = [acquire]; + holdEmperor(g); + return { g, buyer, activation, acquire }; +} + + +void test('retention requires native Advanced Richese, actual unallied Emperor custody and three whole own spice', () => { + const cases: [string, (g: Game) => void][] = [ + ['basic game', g => { g.advanced = false; }], + ['foreign purchaser', g => { g.players[0].faction = 'atreides'; }], + ['native Emperor seated', g => { g.players[1].faction = 'emperor'; }], + ['allied Richese', g => { g.players[0].ally = 'h'; g.players[1].ally = 'r'; }], + ['no dealt Nexus', g => { g.nexusCards!.cards = null; }], + ['another physical holder', g => { + g.nexusCards!.cards!.hands.r = null; + g.nexusCards!.cards!.hands.h = 'emperor'; + }], + ['a different Nexus face', g => { + const cards = g.nexusCards!.cards!; + cards.hands.r = 'guild'; + cards.deck = ['emperor', ...cards.deck.filter(card => card !== 'guild')]; + }], + ['duplicated Emperor Nexus', g => { g.nexusCards!.cards!.discard.push('emperor'); }], + ['only two own spice despite aid', g => { + g.players[0].spice = 2; + g.aid.h = { recipient: 'r', amount: 10 }; + }], + ['fractional own spice', g => { g.players[0].spice = 3.5; }], + ]; + for (const [label, change] of cases) { + const { g, buyer, activation, acquire } = original(); + change(g); + assert.ok(quoteEmperorNexusSpecialPurchase(g, buyer.id).blocked, label); + assert.throws(() => createEmperorNexusSpecialPurchase(g, buyer.id, activation.id, acquire.id), + EmperorNexusPurchaseError, label); + } +}); + +void test('original Richese Nexus profiles compose, but unsupported full overlays cannot grant the benefit', () => { + const single = original(); + single.g.players.pop(); + single.g.order = single.g.players.map(player => player.id); + holdEmperor(single.g); + single.g.homeworlds = { custody: null }; + assert.equal(quoteEmperorNexusSpecialPurchase(single.g, 'r').blocked, null); + const mixed = original(); + mixed.g.expansions = ['choam', 'ix']; + mixed.g.players[1].faction = 'ixians'; + holdEmperor(mixed.g); + assert.equal(quoteEmperorNexusSpecialPurchase(mixed.g, 'r').blocked, null); + const skills = original(); + skills.g.leaderSkills = {} as NonNullable; + assert.equal(quoteEmperorNexusSpecialPurchase(skills.g, 'r').blocked, null); + for (const change of [ + (g: Game) => { g.advancedPreview = true; }, + (g: Game) => { g.ecazTreachery = true; }, + (g: Game) => { g.sandtrout = true; }, + (g: Game) => { g.nexusCards = undefined; }, + (g: Game) => { g.expansions = ['choam', 'ix', 'ecaz']; }, + ]) { + const { g, activation, acquire } = original(); + change(g); + assert.ok(quoteEmperorNexusSpecialPurchase(g, 'r').blocked); + assert.throws(() => createEmperorNexusSpecialPurchase(g, 'r', activation.id, acquire.id), + EmperorNexusPurchaseError); + } +}); + +void test('both selected cards must remain physical singletons across native custody zones before commit', () => { + const duplicate: [string, (g: Game, card: Card) => void][] = [ + ['another hand', (g, card) => { g.players[1].hand.push(card); }], + ['deck', (g, card) => { g.deck.push(card); }], + ['discard', (g, card) => { g.discard.push(card); }], + ['cache', (g, card) => { g.richeseCache!.push(card); }], + ['removed cache', (g, card) => { g.richeseRemoved = [card]; }], + ['Ix setup stock', (g, card) => { g.ixSetupCards = [card]; }], + ['Ix auction stock', (g, card) => { g.ixAuction = { count: 1, cards: [card] }; }], + ]; + for (const selected of ['activation', 'acquire'] as const) { + for (const [zone, change] of duplicate) { + const source = original(); + change(source.g, source[selected]); + assert.throws(() => createEmperorNexusSpecialPurchase( + source.g, 'r', source.activation.id, source.acquire.id, + ), /one exact native cache card/, `${selected} duplicated in ${zone}`); + } + } + for (const change of [ + (g: Game) => { g.players[0].hand = []; }, + (g: Game) => { g.players[1].hand.push(...g.players[0].hand); g.players[0].hand = []; }, + (g: Game) => { g.richeseCache = []; }, + (g: Game) => { g.players[0].hand.push(g.richeseCache!.pop()!); }, + (g: Game) => { g.richeseCache![0] = { ...g.richeseCache![0], name: 'Substituted face' }; }, + (g: Game) => { g.richeseCache![0] = { ...g.richeseCache![0], id: 'invented-cache-card' }; }, + ]) { + const { g, activation, acquire } = original(); + change(g); + assert.throws(() => createEmperorNexusSpecialPurchase(g, 'r', activation.id, acquire.id), + EmperorNexusPurchaseError); + } +}); + +void test('historical source, financial and strict-schema corruption is rejected even when re-signed', () => { + const { g, activation, acquire } = original(); + const receipt = createEmperorNexusSpecialPurchase(g, 'r', activation.id, acquire.id); + const patches: Partial[] = [ + { price: 0 as never }, { price: 2 as never }, { price: 4 as never }, + { beforeSpice: 2, afterSpice: 2 }, { beforeSpice: 3.5, afterSpice: 3.5 }, + { afterSpice: 0 }, { afterSpice: 6 }, + { phase: 0 }, { phase: 9 }, { turn: 0 }, { turn: 3 }, { sequence: -1 }, + { turn: 1 }, { phase: 3 }, { sequence: 1 }, + { owner: 'h' }, { faction: 'harkonnen' as never }, { advanced: false as never }, + { origin: 'cache' as never }, { activation: ' ' }, { activation: acquire.id }, + { card: 'invented-cache-card' }, { card: activation.id }, + { seller: 'r' } as never, { bankSubsidy: 3 } as never, + { auctionIndex: 0 } as never, { entry: 'fabricated-ambassador' } as never, + ]; + for (const patch of patches) { + const changed = { ...receipt, ...patch }; + changed.signature = signEmperorNexusPurchase(changed); + assert.throws(() => validateEmperorNexusPurchase(g, changed), + EmperorNexusPurchaseError, JSON.stringify(patch)); + } + g.players[1].faction = 'emperor'; + assert.throws(() => validateEmperorNexusPurchase(g, receipt), EmperorNexusPurchaseError); + g.players[1].faction = 'harkonnen'; + g.advanced = false; + assert.throws(() => validateEmperorNexusPurchase(g, receipt), EmperorNexusPurchaseError); +}); + diff --git a/tests/nexus-emperor-special-runtime.test.ts b/tests/nexus-emperor-special-runtime.test.ts new file mode 100644 index 00000000..3baa5335 --- /dev/null +++ b/tests/nexus-emperor-special-runtime.test.ts @@ -0,0 +1,210 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, handLimit, viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { quoteEmperorNexusSpecialPurchase } from '../game/nexus-emperor-purchase'; +import { createNexusEmperorSellerFixture } from './fixture-nexus-emperor-seller'; +import { + advanceNexusEmperorSpecial, createNexusEmperorSpecialFixture, nexusEmperorSpecialCards, + nexusEmperorSpecialClean, nexusEmperorSpecialInventory, nexusEmperorSpecialPlayer, + nextNexusEmperorSpecialStep, stepNexusEmperorSpecial, type NexusEmperorSpecialFixture, +} from './fixture-nexus-emperor-special'; + +function purchase(f: NexusEmperorSpecialFixture, joint: boolean): Action { + return { type: 'card', mode: 'special', card: f.activation, acquire: f.acquire, + ...(joint ? { emperorNexus: true, nexusEvent: f.event } : {}) }; +} +function rejectsUnchanged(game: Game, actor: string, action: Action): void { + const before = structuredClone(game); + assert.throws(() => applyAction(game, actor, action)); + assert.deepEqual(game, before, 'Rejection must precede either card disposal, payment or physical acquisition'); +} +function assertPurchase(f: NexusEmperorSpecialFixture, game: Game, joint: boolean): void { + const own = nexusEmperorSpecialPlayer(game, f.owner), before = nexusEmperorSpecialPlayer(f.game, f.owner); + assert.equal(own.spice, f.wallets[f.owner] - (joint ? 0 : 3)); + for (const rival of game.players.filter(p => p.id !== f.owner)) assert.equal(rival.spice, f.wallets[rival.id]); + assert.equal(own.hand.length, before.hand.length, 'One spent physical Karama opens exactly one native hand slot'); + assert.equal(own.hand.filter(c => c.id === f.acquire).length, 1); + assert.equal(own.hand.some(c => c.id === f.activation), false); + assert.equal(game.discard.filter(c => c.id === f.activation).length, 1); + assert.ok(before.hand.filter(c => c.id !== f.activation).every(c => own.hand.some(held => held.id === c.id))); + assert.equal(game.richeseCache!.length, f.game.richeseCache!.length - 1); + assert.equal(game.richeseCache!.some(c => c.id === f.acquire), false); + assert.equal(own.specialKaramaUsed, true); + assert.equal(game.nexusCards!.cards!.hands[f.owner], joint ? null : 'emperor'); + assert.equal(game.nexusCards!.cards!.discard.filter(c => c === 'emperor').length, joint ? 1 : 0); + assert.equal(game.pendingRichesePurchaseIncome ?? null, null); + assert.equal(game.response?.kind === 'richesePurchaseIncome', false); + assert.equal(game.response?.kind === 'harkonnenBonus', false); + assert.deepEqual(game.auction, f.game.auction); + assert.deepEqual(game.richeseAuction, f.game.richeseAuction); + assert.deepEqual(game.currentAuctionSale, f.game.currentAuctionSale); + assert.deepEqual(game.decision, f.game.decision); + assert.deepEqual(game.response, f.game.response); + assert.equal(game.active, f.game.active); + assert.deepEqual(game.movementRemaining, f.game.movementRemaining); + assert.deepEqual(game.players.map(p => [p.id, p.forces, p.reserves, p.shipped, p.moved]), + f.game.players.map(p => [p.id, p.forces, p.reserves, p.shipped, p.moved])); + assert.deepEqual(nexusEmperorSpecialCards(game), nexusEmperorSpecialCards(f.game)); + nexusEmperorSpecialInventory(game); + if (joint) { + assert.equal(game.nexusEmperorPurchaseEvents!.filter(event => event === f.event).length, 1); + } else { + assert.deepEqual(game.nexusEmperorPurchaseHistory ?? [], f.game.nexusEmperorPurchaseHistory ?? []); + assert.deepEqual(game.nexusEmperorPurchaseEvents ?? [], f.game.nexusEmperorPurchaseEvents ?? []); + } +} + + +for (const phase of [2, 4, 5] as const) void test( + `original phase ${phase} owner action: Pay 3 versus Karama + Emperor Nexus keep 3, with the same native successor`, () => { + const f = createNexusEmperorSpecialFixture({ phase }), original = structuredClone(f.game); + const own = viewGame(f.game, f.owner).richeseSpecialKarama!; + assert.equal(own.blocked, null); assert.equal(own.emperorNexus.canUse, true); + assert.equal(own.emperorNexus.blocked, null); + for (const rival of f.game.players.filter(p => p.id !== f.owner)) { + const view = viewGame(f.game, rival.id); + assert.equal(view.richeseSpecialKarama, null); + assert.equal(view.players.find(p => p.id === f.owner)!.hand, undefined); + } + const outcomes = [false, true].map(joint => { + const game = stepNexusEmperorSpecial(f.game, { actor: f.owner, action: purchase(f, joint) }); + assertPurchase(f, game, joint); + rejectsUnchanged(game, f.owner, purchase(f, joint)); + const next = stepNexusEmperorSpecial(game, nextNexusEmperorSpecialStep(game)); + assert.equal(nexusEmperorSpecialPlayer(next, f.owner).hand.filter(c => c.id === f.acquire).length, 1); + assert.equal(next.discard.filter(c => c.id === f.activation).length, 1); + nexusEmperorSpecialInventory(next); + return next; + }); + assert.deepEqual(f.game, original); + assert.equal(outcomes[0].active, outcomes[1].active); + assert.deepEqual(outcomes[0].decision, outcomes[1].decision); + assert.deepEqual(outcomes[0].movementRemaining, outcomes[1].movementRemaining); + }, +); + +void test('wrong owner, stale or coerced fields and unpaired Nexus fields fail before either original cost', () => { + const f = createNexusEmperorSpecialFixture(), valid = purchase(f, true); + for (const rival of f.game.players.filter(p => p.id !== f.owner)) rejectsUnchanged(f.game, rival.id, valid); + for (const action of [ + { ...valid, nexusEvent: 'stale' }, { ...valid, nexusEvent: 1 }, + { ...valid, nexusEvent: null }, { ...valid, nexusEvent: '' }, + { ...valid, emperorNexus: 'true' }, { ...valid, emperorNexus: 1 }, + { ...valid, emperorNexus: false }, { ...valid, emperorNexus: null }, + { type: 'card', mode: 'special', card: f.activation, acquire: f.acquire, emperorNexus: true }, + { type: 'card', mode: 'special', card: f.activation, acquire: f.acquire, nexusEvent: f.event }, + { ...valid, card: f.acquire }, { ...valid, acquire: f.activation }, + { type: 'nexusEmperorPurchase', event: f.event }, + { type: 'nexusEmperorSellerPurchase', event: f.event, use: true }, + ]) rejectsUnchanged(f.game, f.owner, action); + const seller = createNexusEmperorSellerFixture({ advanced: true, winnerFaction: 'richese' }); + rejectsUnchanged(f.game, f.owner, { ...valid, nexusEvent: seller.event }); + nexusEmperorSpecialInventory(f.game); +}); + +void test('a real later phase invalidates the earlier joint quote without consuming the owner entitlement', () => { + const f = createNexusEmperorSpecialFixture(); + const game = advanceNexusEmperorSpecial(f.game, g => g.turn === 2 && g.phase === 4 && nexusEmperorSpecialClean(g)); + assert.equal(game.nexusCards!.cards!.hands[f.owner], 'emperor'); + assert.ok(nexusEmperorSpecialPlayer(game, f.owner).hand.some(c => c.id === f.activation)); + rejectsUnchanged(game, f.owner, purchase(f, true)); + const event = viewGame(game, f.owner).richeseSpecialKarama!.emperorNexus.event; + assert.notEqual(event, f.event); + const next = stepNexusEmperorSpecial(game, { actor: f.owner, action: { ...purchase(f, true), nexusEvent: event } }); + assert.equal(nexusEmperorSpecialPlayer(next, f.owner).spice, nexusEmperorSpecialPlayer(game, f.owner).spice); + assert.equal(next.nexusCards!.cards!.hands[f.owner], null); + assert.equal(nexusEmperorSpecialPlayer(next, f.owner).hand.filter(c => c.id === f.acquire).length, 1); + nexusEmperorSpecialInventory(next); +}); + +void test('the physical cache card reserved in a real original lot cannot be acquired through the joint action', () => { + const f = createNexusEmperorSpecialFixture(); + let game = advanceNexusEmperorSpecial(f.game, g => g.decision?.kind === 'richeseCache' && !g.phaseOpening && !g.response); + game = stepNexusEmperorSpecial(game, { actor: f.owner, action: { type: 'decision', + event: game.richeseBidding!.event, card: f.acquire, method: 'onceAround', direction: 'counterclockwise' } }); + game = advanceNexusEmperorSpecial(game, nexusEmperorSpecialClean); + assert.equal(game.richeseAuction!.cardId, f.acquire); + const event = quoteEmperorNexusSpecialPurchase(game, f.owner).event; + rejectsUnchanged(game, f.owner, { ...purchase(f, true), nexusEvent: event }); + assert.equal(game.nexusCards!.cards!.hands[f.owner], 'emperor'); + assert.equal(game.discard.some(c => c.id === f.activation), false); + assert.ok(game.richeseCache!.some(c => c.id === f.acquire)); + nexusEmperorSpecialInventory(game); +}); + +void test('the irrelevant real closing Guild draw preserves ordinary Pay 3 and never grants joint retention', () => { + const f = createNexusEmperorSpecialFixture({ nexusCard: 'guild' }); + const own = viewGame(f.game, f.owner).richeseSpecialKarama!; + assert.equal(own.blocked, null); + assert.equal(own.emperorNexus.canUse, false); + rejectsUnchanged(f.game, f.owner, purchase(f, true)); + const game = stepNexusEmperorSpecial(f.game, { actor: f.owner, action: purchase(f, false) }); + assert.equal(nexusEmperorSpecialPlayer(game, f.owner).spice, f.wallets[f.owner] - 3); + assert.equal(game.nexusCards!.cards!.hands[f.owner], 'guild'); + assert.equal(game.nexusCards!.cards!.discard.filter(c => c === 'guild').length, 0); + assert.equal(nexusEmperorSpecialPlayer(game, f.owner).hand.filter(c => c.id === f.acquire).length, 1); + assert.equal(game.discard.filter(c => c.id === f.activation).length, 1); + nexusEmperorSpecialInventory(game); +}); + +void test('genuine Basic setup and closing Emperor draw do not unlock the Advanced native special power', () => { + const f = createNexusEmperorSpecialFixture({ advanced: false }); + assert.equal(f.game.nexusCards!.cards!.hands[f.owner], 'emperor'); + assert.equal(viewGame(f.game, f.owner).richeseSpecialKarama, null); + for (const joint of [false, true]) rejectsUnchanged(f.game, f.owner, purchase(f, joint)); + nexusEmperorSpecialInventory(f.game); +}); + +for (const lastCache of [false, true]) void test( + `native full-hand ${lastCache ? 'final-cache' : 'remaining-cache'} exchange uses genuine acquired cards, preserves original capacity and once-use`, () => { + const f = createNexusEmperorSpecialFixture({ fullHand: true, lastCache }); + assert.equal(nexusEmperorSpecialPlayer(f.game, f.owner).hand.length, handLimit(nexusEmperorSpecialPlayer(f.game, f.owner))); + if (lastCache) { + assert.equal(f.game.richeseCache!.length, 1); + assert.equal(f.game.richeseRemoved!.length, 8); + for (const transition of f.chronology.cacheResolutions) { + assert.equal(transition.after.richeseCache!.length, transition.before.richeseCache!.length - 1); + assert.deepEqual(nexusEmperorSpecialCards(transition.after), nexusEmperorSpecialCards(transition.before)); + } + } + for (const joint of [false, true]) { + const game = stepNexusEmperorSpecial(f.game, { actor: f.owner, action: purchase(f, joint) }); + assertPurchase(f, game, joint); + assert.equal(nexusEmperorSpecialPlayer(game, f.owner).hand.length, handLimit(nexusEmperorSpecialPlayer(game, f.owner))); + if (lastCache) assert.equal(game.richeseCache!.length, 0); + rejectsUnchanged(game, f.owner, purchase(f, joint)); + } + }, +); + +void test('projected Easy policy executes the actual native owner acquisition with only its own original entitlement', () => { + for (const nexusCard of ['emperor', 'guild'] as const) { + const f = createNexusEmperorSpecialFixture({ nexusCard, sellerIncome: true }); + const view = viewGame(f.game, f.owner); + view.players.find(p => p.id === f.owner)!.bot = 'Easy'; + const action = botActions(view)[0]; assert.ok(action); + const game = stepNexusEmperorSpecial(f.game, { actor: f.owner, action }); + assert.equal(nexusEmperorSpecialPlayer(game, f.owner).hand.filter(c => c.id === f.acquire).length, 1); + assert.equal(game.discard.filter(c => c.id === f.activation).length, 1); + assert.equal(nexusEmperorSpecialPlayer(game, f.owner).specialKaramaUsed, true); + assert.equal(nexusEmperorSpecialPlayer(game, f.owner).spice, f.wallets[f.owner] - (nexusCard === 'emperor' ? 0 : 3)); + assert.equal(game.nexusCards!.cards!.hands[f.owner], nexusCard === 'emperor' ? null : 'guild'); + assert.equal(game.active, f.game.active); + for (const rival of game.players.filter(p => p.id !== f.owner)) assert.equal(rival.spice, f.wallets[rival.id]); + nexusEmperorSpecialInventory(game); + } +}); + +void test('joint acquisition continues through the next actual turn without repeating either spend or acquisition', () => { + const f = createNexusEmperorSpecialFixture(); + let game = stepNexusEmperorSpecial(f.game, { actor: f.owner, action: purchase(f, true) }); + game = advanceNexusEmperorSpecial(game, g => g.turn === 3 && g.phase === 2 && nexusEmperorSpecialClean(g)); + assert.equal(game.discard.filter(c => c.id === f.activation).length, 1); + assert.equal(game.nexusCards!.cards!.discard.filter(c => c === 'emperor').length, 1); + assert.equal(game.nexusEmperorPurchaseEvents!.filter(event => event === f.event).length, 1); + assert.equal(nexusEmperorSpecialPlayer(game, f.owner).hand.filter(c => c.id === f.acquire).length, 1); + assert.equal(nexusEmperorSpecialPlayer(game, f.owner).specialKaramaUsed, true); + assert.equal(viewGame(game, f.owner).richeseSpecialKarama, null); + nexusEmperorSpecialInventory(game); +});