diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index f4b44114..52d45b65 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -29,6 +29,24 @@ 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 original native bank purchase — 14 October.** Normal bank retention +now uses original native Nexus roster/module profiles and their own canonical +optional envelopes, preserving exact prior classic admission. Uniform public +payment, personally affordable positive price, original acquisition/bonus/ +Atreides next inspection and unchanged bank receipt remain. No bank subsidy or +Richese seller/cache route is invented; strict legacy physical revival guard +stays classic and never replays forces. +Affected16-file union217/217, types/lint/build and thirty-six actual API profiles +pass: all six natives Basic/Advanced Pay/Use plus Advanced Homeworld/Tech. +Actual390px Richese5→5/Ecaz12→12 retain modules, Basic Tleilaxu5→5 and wrong-face +CHOAM Pay14→12 preserve one actual card and index0→1; Atreides sees next card. +No overflow/errors or saved/deployed room touched. Fixture-only forced-response +and five-disc Auditor-exclusion pins are deleted. Existing public assets are +served in QA; this does not certify complete artwork or combinations. +[Canonical source/proof](NEXUS_EMPEROR_RULES.md#original-native-bank-auction-admission--14-october-2026). +Broad assurance, AI calibration and deployed/publication gates remain. Proof +receipts stay outside Git; existing build route/chunk warnings remain. + **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 diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index d8331de5..06952733 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,39 @@ # Dune implementation status +## 14 October 2026 — Emperor Nexus original native bank admission + +Removed the classic-only prototype bank fence in favor of existing canonical +native Nexus public profile envelopes. Uniform winner payment remains private- +face independent; actual unallied holder proves its positive full own price, +spends one real Nexus and receives one original normal bank lot with unchanged +wallet. Original card capacity, paid/Karama alternatives, native Harkonnen bonus +and Atreides next inspection stay. No bank-created income or Richese seller/cache +route is invented. The no-version bank receipt/event/signature stays unchanged. + +Exact previously accepted classic condition is reused, not duplicated or +silently narrowed. The old guard is explicitly named for strict historical +physical revival evidence; that source never broadens or replays forces. +New native aliases do not open unsupported packets or combinations. Existing +minimal bank adapter and production controls are reused without new actions. + +Affected16-file union217/217 and types/lint/build pass. Thirty-six actual native +API profiles cover every six natives in Basic/Advanced Pay/Use plus Advanced +Homeworld/Tech Pay/Use, retaining original card/module inventory and next-lot +inspection. Actual390px Richese5→5 and Ecaz12→12 preserve Homeworld/Tech; +Basic Tleilaxu5→5 and irrelevant Guild-Nexus CHOAM Pay14→12 keep original costs, +one card and index0→1. Actual Atreides surface sees the next Maula Pistol. +No overflow/browser errors or saved/deployed rooms touched. + +Fixture assumptions forcing a cancellation response with no canceling card +or a universal five-disc CHOAM roster ignoring native Auditor are removed, +not re-pinned. Native automatic bonus/inspection remains. Pure field/action/ +default assertions are removed; later actual purchases prove historical receipt +acceptance. QA static-root omission was corrected to serve existing bundled +art/fonts; this is not complete presentation acceptance. Proof receipts remain +outside Git; existing build classification/chunk warnings stay. Full combinations, +broad assurance, AI calibration and deployed/publication acceptance remain open. +[Canonical native source/proof](NEXUS_EMPEROR_RULES.md#original-native-bank-auction-admission--14-october-2026). + ## 14 October 2026 — Emperor Nexus joint native special acquisition Connected original Advanced Richese special Karama purchase to the printed diff --git a/docs/NEXUS_CARD_RULES.md b/docs/NEXUS_CARD_RULES.md index 17488e61..7bfbe00a 100644 --- a/docs/NEXUS_CARD_RULES.md +++ b/docs/NEXUS_CARD_RULES.md @@ -12,6 +12,17 @@ movement, battle geometry and actual casualties stay unchanged. One original Karama response cancels the entire physical batch and keeps the Nexus spent. [Source, original native proofs and controls](NEXUS_BENE_GESSERIT_RULES.md#recommended-cunning-card-exceptions--14-october-2026). +## Emperor original native bank purchase — 14 October 2026 + +Normal bank-auction retention now uses existing original native Nexus public +profiles and their canonical optional envelopes, with exact old classic +admission preserved. Uniform winner payment does not depend on private face. +Actual unallied holder proves the positive own price, spends one Nexus and +acquires one original bank lot without payment; original bonus/next inspection +remain. Richese ordinary pool is not a cache/seller source. Bank receipt stays +unchanged; strict legacy physical revival guard is not broadened. +[Source and actual native proof](NEXUS_EMPEROR_RULES.md#original-native-bank-auction-admission--14-october-2026). + ## Recommended Emperor joint special purchase — 14 October 2026 Original Advanced Richese special Karama now has a private joint option: diff --git a/docs/NEXUS_EMPEROR_RULES.md b/docs/NEXUS_EMPEROR_RULES.md index b37e27d2..7aab8066 100644 --- a/docs/NEXUS_EMPEROR_RULES.md +++ b/docs/NEXUS_EMPEROR_RULES.md @@ -54,15 +54,16 @@ source-bound continuation; do not broaden a generic acquisition hook. ### Bounded bank-auction purchase prototype (27 September 2026) -In Basic/Advanced classic-faction Nexus-only games with Emperor absent, the +The initial 27 September prototype admitted Basic/Advanced classic Nexus games. +The 14 October native cutover below now extends that public bank context. The unallied holder can spend the physical card at a normal Bidding winner-payment window. The full positive final bid must be affordable from the buyer's own spice without ally contribution. The bank receives no spice; the winner keeps that balance, receives the one purchased physical Treachery Card and continues through normal auction follow-ups. One private signed receipt records the original lot, final price and unchanged buyer balance across JSON/SQLite -recovery. Its classic-only admission remains distinct from the new Richese -seller continuation; no allied split or generic acquisition hook is inferred. +recovery in the initial classic scope. Bank purchase remains distinct from +Richese seller/direct acquisition sources; no allied split is inferred. Every winner in this bounded public auction context receives the same payment confirmation, even without the card or Karama. Otherwise a pause only for the @@ -72,6 +73,40 @@ the recommendation is selected under the user's 14 October decision priority. The normal spice and Karama payment choices remain available where legal. +### Original native bank-auction admission — 14 October 2026 + +The same printed purchase now applies in existing original native Nexus +roster/module envelopes: single/paired/mixed E1/E2 and standalone/paired/mixed +E3, including each canonical predicate's own Skills/Discovery/Homeworld/Tech/ +Stronghold composition. This removes a prototype classic-only fence, not a +new operation, timing ruling or permission to create an unsupported packet. +No native Emperor may be seated; actual holder must be unallied, hold the full +positive price personally, and meet original source/hand-capacity rules. + +Uniform public winner payment remains independent of concealed face. Original +normal bank lot, paid/Karama choices, one physical acquisition, Harkonnen bonus +and next-lot Atreides inspection continue; the bank receives nothing on Use. +Native Richese's ordinary pool is not its cache/Black Market; no seller income +or extra source is created. Existing bank receipt/event/signature stays unchanged. +The old classic predicate is explicitly retained/reused for prior admission +parity and strict **legacy physical revival** evidence. No legacy force replay +or native return-history broadening follows the new bank admission. + +Affected16-file union217/217, types/lint/build and thirty-six actual native API +profiles pass. Every six expansion natives has Basic/Advanced Pay/Use and +Advanced Homeworld/Tech Pay/Use, preserving actual wallets/card/module inventory +and next-lot inspection. Four actual390px surfaces prove Richese5→5 and +Ecaz12→12 with original Homeworld/Tech unchanged, Basic Tleilaxu5→5, and +irrelevant-card CHOAM Pay14→12 with Guild Nexus retained. One original card, +actual Nexus cost and index0→1 remain; Atreides sees the actual next card. +No overflow/browser errors; no saved/deployed room touched. Full combinations, +assurance and publication/deployed gates remain. + +Fixture-only assumptions requiring a response when nobody can cancel, or a +universal five-disc CHOAM roster excluding Auditor, are deleted rather than +re-pinned. Native automatic inspection/bonus remains original. QA now serves +the existing public asset root; this is not complete artwork acceptance. + ### Recommended Richese seller-unpaid purchase — 14 October 2026 Original positive Richese cache and Advanced-only Black Market sales now offer diff --git a/docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md b/docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md index aeaa3db4..b2ddafdc 100644 --- a/docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md +++ b/docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md @@ -2,10 +2,10 @@ 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, -the Richese Ambassador and joint Richese special Karama have distinct native -continuations. Public starts and publication remain gated. +continuations and supported Nexus module envelopes. Ordinary bank-auction +purchase now uses existing classic/native Nexus profile admission; Richese +cache/Advanced Black Market, Ambassador and joint special Karama retain their +distinct native sources. Public starts and publication remain gated. ## Rules and connected behavior @@ -52,8 +52,9 @@ cannot use ally contribution. One actual Treachery lot enters their hand, their spice remains unchanged, and normal auction/card follow-ups continue. The public log announces the price/proof/use without revealing excess spice or the Treachery card face. A private signed receipt records the lot, price and -unchanged balance; independent use markers and physical custody survive JSON -and in-memory SQLite CAS/restart. Rival seat views omit the offer and receipt. +unchanged balance; existing classic JSON/SQLite evidence remains historical. +Native bank cutover has separate changed-path proof below, not a new recovery +certification. Rival seat views omit the offer and receipt. **Timing is a provisional privacy choice, not a user or publisher ruling.** Every winner in this bounded public auction context receives the same payment @@ -64,6 +65,16 @@ owned and otherwise use the ordinary decision. The recommended confirmation is selected under the user's 14 October decision priority; it does not certify complete release timing. +The 14 October bank cutover now admits original single/paired/mixed E1/E2 and +standalone/paired/mixed E3 public Nexus profiles and their canonical optional +module envelopes, while preserving exact old classic admission. Original Pay, +personally affordable Use, card/hand limit, native bonus and next inspection +stay unchanged; no fake money or seller payment. Bank receipt format/event is +unchanged. Strict old physical revival evidence still uses its explicit classic +guard, not the new bank profile. Native aliases do not create unsupported starts. +[Native bank source and proof](NEXUS_EMPEROR_RULES.md#original-native-bank-auction-admission--14-october-2026). + + Original Richese cache and Advanced Black Market now connect a separate uniform winning-buyer confirmation after the original Richese Betrayal window. Use spends the actual Nexus and delivers one original lot with unchanged buyer diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index caa00dc5..b1e14182 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -66,6 +66,18 @@ These decisions do not open public module/faction or complete-mode gates. These decisions do not open public module/faction or complete-mode gates. +## Emperor original native bank admission — 14 October 2026 + +Apply the existing printed retained-price purchase to original normal bank +auctions in canonical native Nexus roster/module envelopes, without a +classic-only prototype fence. Exact old classic public admission remains; +native aliases do not grant new packet support. Uniform payment timing retains +its provisional privacy recommendation. Original positive own price, no ally +share, hand room, normal acquisition/bonus/next inspection and zero bank +payment remain. Bank receipt format is unchanged; old physical revival evidence +keeps its explicit classic guard and never replays forces. +[Source, boundaries and changed-path proof](NEXUS_EMPEROR_RULES.md#original-native-bank-auction-admission--14-october-2026). + ## Recommended Emperor joint special acquisition — 14 October 2026 Apply the printed purchase panel to original Advanced Richese special Karama, @@ -642,7 +654,7 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t | Moritani Secret Ally battle timing | The printed after-loss retention is optional and the Nexus card remains secret. The connected prototype offers the same choice to every loser when Moritani is absent and at least one publicly revealed played card is retainable; a loser with no eligible public card has no useful choice, independent of hidden custody. Only an unallied holder may spend the physical card. This prompt is a privacy-preserving product inference, not publisher timing text. Native Moritani alliance retention and its Karama response remain separate; no generic Secret Ally counter is invented. | [Nexus retention boundary](NEXUS_CARD_RULES.md#moritani-secret-ally-retention-timing) | | Ecaz Secret Ally traitor inquiry | The printed panel asks one selected player whether they hold any of the holder's leaders as Traitors, without identifying a leader; it specifies neither native-versus-controlled scope nor answer audience. The connected prototype chooses native printed faction leaders (including dead/captured), ordinary Traitor Cards rather than Face Dancers, and a holder-only historical yes/no. This is a privacy-preserving product interpretation, **not** a publisher ruling. Foreign Ghola/captive scope and table-wide disclosure remain release boundaries. | [Ecaz inquiry source and bounded runtime](NEXUS_ECAZ_RULES.md#bounded-secret-ally-traitor-inquiry-runtime) | | Ecaz native Nexus Cunning timing | The printed Cunning takes Duke for this turn even from capture, Tanks or Ghola, without a phase. The quiet Battle prototype now covers living control or set-aside Tanks. Tanks preserve deaths and reset use under a provisional independent-resurrection policy; this is not a numerical user ruling. Captured/Ghola return destinations and Advanced Harkonnen remain guarded; ordinary Ambassador and paid-revival custody are unchanged. | [Source and bounded runtime](NEXUS_ECAZ_RULES.md#bounded-native-cunning-duke-vidal) | -| Emperor Secret Ally purchase timing | Uniform buyer/committed-beneficiary confirmation prevents a holder-only pause disclosing hidden eligibility; no beneficiary veto is added. Distinct classic bank, Richese cache/Advanced Black Market and original Richese Ambassador paths now select visibly provisional source recommendations under the user's decision priority, not publisher timing rulings. Other real purchase sources and full combinations retain separate contracts. CHOAM closing sales/trades are not direct purchases. | [Original Ambassador policy](NEXUS_EMPEROR_RULES.md#recommended-original-ambassador-purchase--14-october-2026), [seller policy](NEXUS_EMPEROR_RULES.md#recommended-richese-seller-unpaid-purchase--14-october-2026) | +| Emperor Secret Ally purchase timing | Uniform buyer/committed-beneficiary confirmation prevents a holder-only pause disclosing hidden eligibility; no beneficiary veto. Original classic/native bank admission, Richese cache/Advanced Black Market, Ambassador and joint special Karama keep distinct sources. Uniform timing and seller/direct compositions remain visibly provisional recommendations, not publisher timing rulings. Old bank receipt stays unchanged; strict legacy physical revival never broadens. Full combinations remain open; CHOAM closing sales/trades are not direct purchases. | [Native bank source](NEXUS_EMPEROR_RULES.md#original-native-bank-auction-admission--14-october-2026), [Ambassador](NEXUS_EMPEROR_RULES.md#recommended-original-ambassador-purchase--14-october-2026), [seller](NEXUS_EMPEROR_RULES.md#recommended-richese-seller-unpaid-purchase--14-october-2026), [joint special](NEXUS_EMPEROR_RULES.md#recommended-joint-richese-special-purchase--14-october-2026) | | Discovery prototype inferences | Free entry uses storm order and ordinary source-storm restrictions; these are general-rule inferences. Great Maker currently follows ordinary first-turn worm skipping and Sandtrout suppression; an exhausted token type creates no replacement token. These preserve current source interpretation and physical inventory but are not separately confirmed rulings or complete combined-module certification. | [Interpretations for refinement](DISCOVERY_PROTOTYPE.md#interpretations-retained-for-refinement) | | Other Homeworld combinations | Population/force splitting, removal custody, replacement shortages, income accounting and placement ordering remain local to their detailed contracts. | [Homeworld rules](HOMEWORLD_RULES.md), [occupation](HOMEWORLD_OCCUPATION_RULES.md) and the effect documents above | diff --git a/game/engine.ts b/game/engine.ts index ea709700..fbcb81ce 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -44,8 +44,8 @@ import { quoteNexusMoritaniRetention, nexusMoritaniRetentionSignature, type Nexu import { quoteNexusChoamBetrayal, validateNexusChoamBetrayal, nexusChoamBetrayalSignature, type NexusChoamBetrayal } from './nexus-choam-betrayal'; 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, quoteEmperorNexusSpecialPurchase, createEmperorNexusSpecialPurchase, type EmperorNexusPurchase } from './nexus-emperor-purchase'; +import { EMPEROR_NEXUS_REVIVALS, emperorNexusLegacyRevivalModeSupported, quoteEmperorNexusRevival, createEmperorNexusRevival, settleEmperorNexusRevival, validateEmperorNexusRevival, type NexusEmperorRevival } from './nexus-emperor-secret-ally'; +import { emperorNexusPurchaseEvent, quoteEmperorNexusPurchase, signEmperorNexusPurchase, validateEmperorNexusPurchase, emperorNexusBankPurchaseModeSupported, 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'; @@ -2735,8 +2735,7 @@ function playNexusBgBetrayal(g: Game, p: Player, action: Action) { } /** The public payment window must not depend on who privately holds Emperor Nexus. */ function emperorNexusUniformAuctionPayment(g: Game): boolean { - return !!g.nexusCards?.cards && !byFaction(g,'emperor') && - emperorNexusModeSupported(g) && !g.ecazTreachery && !g.sandtrout && + return !!g.nexusCards?.cards && emperorNexusBankPurchaseModeSupported(g) && !!g.auction && !g.currentAuctionSale; } @@ -2754,8 +2753,8 @@ function nexusEmperorSecretAllyOffer(g:Game,owner:string) { if (purchasing) { let purchaseBlock:string|null = null; if (p.ally) purchaseBlock = 'Emperor Nexus Secret Ally requires an unallied buyer.'; - else if (!emperorNexusModeSupported(g) || g.ecazTreachery || g.sandtrout) - purchaseBlock = 'Emperor Nexus purchase requires a supported classic Nexus profile; unrelated modules remain unavailable.'; + else if (!emperorNexusBankPurchaseModeSupported(g)) + purchaseBlock = 'Emperor Nexus purchase requires its supported original Nexus profile and normal bank auction.'; else if (g.response || g.truthtrance || g.phaseOpening || g.pendingKarama || g.pendingTreacheryDiscard || g.pendingNullentropy || g.pendingExchange || g.pendingRicheseGift || g.pendingRichesePurchaseIncome || g.pendingAmbassador || @@ -2783,7 +2782,7 @@ function nexusEmperorSecretIntegrity(g:Game) { for (const [index,record] of history.entries()) { nexusRule(() => validateEmperorNexusRevival(g,record)); if (!('version' in record)) - requireRule(emperorNexusModeSupported(g), + requireRule(emperorNexusLegacyRevivalModeSupported(g), 'A legacy Emperor return lost its original supported roster.'); else if (record.stage === 'requested') requireRule(index === history.length - 1 && g.pendingRevival?.nexusEmperorEvent === record.event && @@ -2807,7 +2806,7 @@ function nexusEmperorSecretIntegrity(g:Game) { for (const [index,record] of purchases.entries()) { nexusRule(() => validateEmperorNexusPurchase(g,record)); if (!('version' in record)) - requireRule(emperorNexusModeSupported(g) && !g.ecazTreachery && !g.sandtrout, + requireRule(emperorNexusBankPurchaseModeSupported(g), 'A normal-bank Emperor purchase lost its original supported profile.'); else if (record.version === 3) requireRule(emperorNexusAmbassadorPurchaseModeSupported(g), diff --git a/game/nexus-emperor-purchase.ts b/game/nexus-emperor-purchase.ts index 5348d57f..fbeec60f 100644 --- a/game/nexus-emperor-purchase.ts +++ b/game/nexus-emperor-purchase.ts @@ -1,17 +1,18 @@ import { FACTIONS, type FactionId } from './catalog'; import { handLimit, type Game, type Player } from './engine'; -import { emperorNexusEvent } from './nexus-emperor-secret-ally'; +import { emperorNexusEvent, emperorNexusLegacyRevivalModeSupported } from './nexus-emperor-secret-ally'; import { nexusCardMode, validateNexusCards } from './nexus-cards'; import { validAmbassadorResume } from './ambassador-resume'; import { ambassadorPhaseAllowed } from './ambassador-phase'; import { - pairedNexusModulesProfile, singleE1E2NexusProfile, mixedE1E2NexusProfile, mixedE3NexusProfile, - standaloneE3NexusProfile, pairedE3NexusProfile, + pairedNexusModulesProfile, singleE1E2NexusProfile, + mixedE1E2NexusProfile, mixedE3NexusProfile, standaloneE3NexusProfile, pairedE3NexusProfile, } from './nexus-module-profile'; import { - pairedNexusLeaderSkillsProfile, singleE1E2NexusLeaderSkillsProfile, - mixedE1E2NexusLeaderSkillsProfile, mixedE3NexusLeaderSkillsProfile, - standaloneE3NexusLeaderSkillsProfile, pairedE3NexusLeaderSkillsProfile, + pairedNexusLeaderSkillsProfile, + singleE1E2NexusLeaderSkillsProfile, mixedE1E2NexusLeaderSkillsProfile, + mixedE3NexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, + pairedE3NexusLeaderSkillsProfile, } from './leader-skill-profile'; import type { RicheseAuction } from './richese-auction'; import { quoteRicheseSettlement, type RicheseSettlementQuote } from './richese-settlement'; @@ -345,6 +346,45 @@ export function validateEmperorNexusPurchase( ); } + +/** Genuine native Nexus envelopes only; the canonical native profiles own their + * roster and delegated Discovery/Homeworld/Tech/Stronghold envelopes, and the + * separately flagged preview aliases never open one. */ +function nativeBankNexusProfile(g: Game): boolean { + if (g.semutaPreview || g.advancedPreview || g.kullPreview || g.nexusKullPreview || + g.guildBetrayalPreview || g.richeseBetrayalPreview || + g.nexusIxianReplacementPreview || g.nexusIxianBetrayalPreview || + g.nexusHarkonnenBetrayalPreview) return false; + if (g.leaderSkills) + return pairedNexusLeaderSkillsProfile(g) || singleE1E2NexusLeaderSkillsProfile(g) || + mixedE1E2NexusLeaderSkillsProfile(g) || standaloneE3NexusLeaderSkillsProfile(g) || + pairedE3NexusLeaderSkillsProfile(g) || mixedE3NexusLeaderSkillsProfile(g); + if (pairedNexusModulesProfile(g) || singleE1E2NexusProfile(g) || + mixedE1E2NexusProfile(g) || standaloneE3NexusProfile(g) || + pairedE3NexusProfile(g) || mixedE3NexusProfile(g)) return true; + // Bare paired E1/E2 Nexus without Tech or Strongholds. + if (g.techTokens || g.strongholdCards || g.discoveryEnabled || g.discoveries || + g.discoveryStash || g.greatMaker || g.players.length < 2 || g.players.length > 6) + return false; + for (let i = 0; i < g.players.length; i++) + for (let j = 0; j < i; j++) + if (g.players[j].faction === g.players[i].faction) return false; + if (g.expansions.length !== 1 || !['ix', 'choam'].includes(g.expansions[0])) return false; + const expansion = g.expansions[0]; + return g.players.every(p => FACTIONS.some(f => f.id === p.faction && + (f.expansion === 'base' || f.expansion === expansion))) && + g.players.filter(p => FACTIONS.some(f => f.id === p.faction && f.expansion === expansion)).length === 2; +} + +/** Normal bank-auction admission only: a supported original native Nexus roster + * with the Emperor absent, the concealed face and any buyer wallet or hand left + * to the caller. */ +export function emperorNexusBankPurchaseModeSupported(g: Game): boolean { + if (!g.nexusCards || g.players.some(p => p.faction === 'emperor') || + g.ecazTreachery || g.sandtrout) return false; + return emperorNexusLegacyRevivalModeSupported(g) || nativeBankNexusProfile(g); +} + /** Reuse native Nexus profiles; the existing Richese Betrayal preview composes * only on its original bare paired CHOAM/Richese roster, not arbitrary overlays. */ export function emperorNexusSellerPurchaseModeSupported(g: Game): boolean { diff --git a/game/nexus-emperor-secret-ally.ts b/game/nexus-emperor-secret-ally.ts index 95778576..1fad769d 100644 --- a/game/nexus-emperor-secret-ally.ts +++ b/game/nexus-emperor-secret-ally.ts @@ -88,7 +88,7 @@ export function emperorNexusEvent( export function emperorNexusSignature(record: object): string { return JSON.stringify({ ...record, signature: undefined }); } -export function emperorNexusModeSupported(g: Game): boolean { +export function emperorNexusLegacyRevivalModeSupported(g: Game): boolean { return ( !g.expansions.length && g.players.every((p) => diff --git a/game/reference.ts b/game/reference.ts index 8ae0e0f8..d998af86 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -4355,24 +4355,24 @@ 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, Richese cache/Advanced Black Market, Ambassador and joint special Karama have distinct connected paths. Full combinations and assurance retain separate gates.', + summary: 'With Emperor absent, an unallied holder can choose exactly three extra free returns or retain a personally affordable original purchase price. Original classic/native 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.', 'The Advanced one-per-turn Fedaykin limit still applies across ordinary revivals and this card. The extra total allowance does not permit another elite revival. Fewer than three eligible counters is an unresolved case and remains unavailable.', 'The independent revival grant enters original pending revival. Under the visible provisional policy, native Tleilaxu prevention/income, typed reserves, Tech and eligible Homeworld handling remain; ordinary free-rate blocking does not reprice it. Keeping the card creates no extra public confirmation.', - '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.', + 'At an eligible original classic/native Nexus normal bank-auction winner payment, prove the positive final bid from your own spice and spend Emperor Nexus to receive the lot without losing spice. Every winner sees the same payment confirmation; the provisional privacy recommendation remains. Canonical native module envelopes preserve original card/capacity, bonus and next-lot inspection; ordinary spice or legal Karama remains available. No bank subsidy or Richese cache/seller route is invented. Strict old physical revival admission is separate and never broadens.', 'Original Richese cache and Advanced Black Market offer Pay or eligible Emperor Nexus Use after original Richese Betrayal. The provisional seller-unpaid policy retains your full personally affordable price without bank reimbursement. One original lot, native Harkonnen bonus and source successor continue; legal self-cache is included but self-Black remains prohibited. Original self-purchase forced discard cannot consume this Nexus or acquire a card.', '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/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: 'Implementation', status: 'Partial', detail: 'Independent extra-three and seller/Ambassador/joint sources remain. Normal bank purchase now uses canonical native Nexus admission plus exact old classic parity, preserving its original price, card/capacity/bonus/successor and receipt format. Strict legacy physical revival stays classic. No subsidy or extra seller source; 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: 'Existing uniform bank payment, private eligible Use and original next-lot controls now work for native buyers. Actual390px Richese/Ecaz Homeworld-Tech and Basic Tleilaxu retain price; wrong-face CHOAM Pay and Atreides inspection remain. Existing typed revival and seller/Ambassador/joint controls stay connected; complete-mode acceptance remains open.', 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 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'] }, + { area: 'Verification', status: 'Partial', detail: 'Native bank16-file union217/217, types/lint/build and thirty-six real API profiles pass: all six natives Basic/Advanced Pay/Use and Advanced Homeworld/Tech Pay/Use. Four actual390px surfaces prove Richese5 to5/Ecaz12 to12 with modules unchanged, Basic Tleilaxu5 to5 and Guild-card CHOAM Pay14 to12, one card/index0 to1 and actual next Atreides inspection, no overflow/errors. Prior joint112/112, Ambassador111/111, seller108/108 and other 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', 'tests/fixture-nexus-emperor-bank.ts', 'tests/nexus-emperor-bank-profile.test.ts', 'tests/nexus-emperor-bank-runtime.test.ts'] }, ], }, { @@ -4580,7 +4580,8 @@ export const RULE_TOPICS: RuleTopic[] = [ 'With BG absent, every fresh Nexus battle asks both combatants in attacker order before Prescience, Nexus inspection or sealed plans. Continue preserves the card. The actual unallied BG-card holder may spend it to require or forbid one canonical type against the opponent. Under the explicit provisional recommendation, original Karama can cancel this named Voice advantage; the Nexus stays spent, only Voice is removed and native preparation continues. Existing in-flight preparation is not retrofitted. Native compliance and special/mode restrictions remain; this does not complete other unfinished effects.', 'Bene Gesserit Betrayal can prevent the native Voice while the ordinary pre-plan response is open. In Nexus games every seat responds even without a canceling card, so the window does not reveal the hidden holder. An unallied opponent spends one physical Bene Gesserit Nexus card to remove only that declared Voice; Prescience and plan commitments continue. Truthtrance has priority. This path does not grant borrowed Voice.', 'Emperor Cunning requires an Advanced battle with at least five ordinary Emperor counters and no actual Sardaukar, before submitting your plan. The played Nexus card opens one Karama response. Allowing it makes five ordinary counters count as Sardaukar for this battle; cancellation prevents the entire enhancement and leaves the Nexus card spent.', - 'Temporary Sardaukar use the applicable strength and support rules, including the Fremen exception, but remain physically ordinary counters. Their losses go to Tanks as ordinary counters, without changing starred reserves or Tanks. Existing inspected or promised plans remain binding. The role ends after this battle; fewer-than-five groups remain unavailable. Secret Ally independent revival, classic bank and original Richese seller purchases have connected controls; compulsory allied funding remains the separate operation below. Other acquisition sources and full combinations remain open.', + 'Temporary Sardaukar use applicable strength/support rules, including the Fremen exception, but remain physical ordinary counters. Losses go to Tanks as ordinary, without changing starred pools. Existing inspected/promised plans bind and the role ends after this battle; fewer-than-five remains unavailable. Secret Ally independent revival and original bank/seller/Ambassador/joint special purchases have connected controls; compulsory funding stays separate. Full combinations remain open.', + 'Original normal bank-auction purchase now uses canonical native Nexus roster/module admission and exact prior classic parity. Uniform winner payment is private-face independent. Actual unallied holder proves a positive full own price, spends one Nexus and acquires one original lot without bank payment. Native card capacity, bonus and next Atreides inspection remain; Richese ordinary pool is not a seller/cache route. Bank receipt format is unchanged, and strict legacy physical revival remains separately classic without replay. Unsupported native aliases and full-mode gates stay closed.', '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.', @@ -4614,11 +4615,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 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"]}, + {area:'Implementation',status:'Partial',detail:"Existing independent revival and seller/Ambassador/joint special sources remain. Normal bank purchase now admits canonical native Nexus public profiles and their optional envelopes, preserving exact old classic admission. Actual own positive price, no ally share, native card/capacity/bonus/next inspection and unchanged bank receipt remain; no subsidy or Richese cache/seller source is invented. Strict legacy physical revival stays classic. Complete 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:"Original uniform bank winner payment and eligible private Nexus Use now work for native rosters; no hidden-face-only pause. Existing Pay/Karama, native bonus and next inspection controls are reused. Actual390px Richese5 to5/Ecaz12 to12 preserve Homeworld/Tech, Basic Tleilaxu5 to5 and wrong-face CHOAM Pay14 to12 preserve one card and next index. No overflow/errors. Existing seller/extra/joint controls remain; full-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", "components/richese-special-karama.tsx"]}, + {area:'AI',status:'Partial',detail:"Existing minimal bank adapter uses the actual shared legal projection for native buyers, or original Pay for an irrelevant face, without rival private inspection. Genuine Easy CHOAM continuations cover both faces. Existing extra/seller/joint adapters and conservative budgets remain; strategy and four-tier 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 bank purchase is no longer fenced to classic prototypes; canonical native admission and exact old classic parity are explicit. Uniform payment timing retains its provisional privacy recommendation, not a new publisher ruling. Legacy physical revival remains separately guarded. Seller/Ambassador/joint interpretations, unsupported native aliases and full-mode gates remain distinct.",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:"Native bank affected16-file union217/217, types/lint/build and thirty-six actual API profiles pass: all six natives Basic/Advanced Pay/Use and Advanced Homeworld/Tech Pay/Use. One actual card, original wallets/module inventory and next-lot inspection remain. Four actual390px surfaces prove Richese5 to5/Ecaz12 to12 with modules unchanged, Basic Tleilaxu5 to5 and Guild-card CHOAM Pay14 to12; actual Atreides sees next Maula Pistol. No overflow/errors or saved/deployed rooms touched. Forced-response and five-disc Auditor-exclusion fixture pins were deleted. Prior joint112/112, Ambassador111/111, seller108/108 and other 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", "tests/fixture-nexus-emperor-bank.ts", "tests/nexus-emperor-bank-profile.test.ts", "tests/nexus-emperor-bank-runtime.test.ts"]}, ], }, { diff --git a/tests/fixture-nexus-emperor-bank.ts b/tests/fixture-nexus-emperor-bank.ts new file mode 100644 index 00000000..2eb0f560 --- /dev/null +++ b/tests/fixture-nexus-emperor-bank.ts @@ -0,0 +1,190 @@ +import assert from 'node:assert/strict'; +import { + applyAction, createGame, initializeNexusGameForAudit, joinGame, newPlayer, + type Action, type Game, type Player, +} from '../game/engine'; +import { treacheryDeck, type Card } from '../game/cards'; +import { richeseCards } from '../game/richese-cards'; +import { validateNexusCards } from '../game/nexus-cards'; +import { validateLeaderSkills } from '../game/leader-skills'; +import { validateAmbassadors } from '../game/ecaz-ambassadors'; +import { emperorNexusPurchaseEvent } from '../game/nexus-emperor-purchase'; +import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game'; +import { homeworldForceGroups } from '../game/homeworld-custody'; +import { createSingleNexusE1Setup, nextSingleNexusE1Step } from './fixture-single-nexus-e1'; +import { createSingleE2Setup, nextSingleE2Step } from './fixture-single-nexus-e2'; +import { createE3NexusEcazSetup, nextE3NexusEcazStep, orderE3NexusEcazSpice } from './fixture-e3-nexus-ecaz'; +import { createMoritaniNexusSetup, nextMoritaniNexusStep } from './fixture-e3-nexus-moritani'; +import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions'; +import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus'; + +export type NexusEmperorBankOptions = { + advanced?: boolean; + profile?: 'ixians' | 'tleilaxu' | 'richese' | 'choam' | 'ecaz' | 'moritani'; + nexusCard?: 'emperor' | 'guild'; + homeworlds?: boolean; + tech?: boolean; +}; +export type NexusEmperorBankStep = { actor: string; action: Action }; +export type NexusEmperorBankTransition = { before: Game; step: NexusEmperorBankStep; after: Game }; +export type NexusEmperorBankFixture = { + game: Game; owner: string; event: string; card: Card; price: number; + wallets: Record; actions: NexusEmperorBankStep[]; + chronology: { + initial: Game; afterSetup: Game; firstMentat: NexusEmperorBankTransition; + worm: Game; alliance: Game; draw: NexusEmperorBankTransition; + afterClosingDraw: Game; beforeBid: Game; + }; +}; +export function nexusEmperorBankPlayer(game: Game, actor: string): Player { + const player = game.players.find(p => p.id === actor); assert.ok(player); return player; +} +const clean = (g: Game) => !g.phaseOpening && !g.response && !g.decision && + !g.truthtrance && !g.pendingTreacheryDiscard; + +export function stepNexusEmperorBank(game: Game, step: NexusEmperorBankStep, + actions?: NexusEmperorBankStep[]): Game { + actions?.push(structuredClone(step)); + return withClassicDiscoveryNexusLottery(() => applyAction(structuredClone(game), step.actor, step.action)); +} +/** Pass original optional windows and cache bids; never manufacture a plan or acquire cache fuel. */ +export function nextNexusEmperorBankStep(game: Game): NexusEmperorBankStep { + if (clean(game) && game.nexusCards?.phase?.stage === 'drawing') { + const actor = game.nexusCards.phase.eligible.find(id => !game.nexusCards!.phase!.done.includes(id)); + assert.ok(actor); + return { actor, action: { type: 'nexusCardChoice', turn: game.turn, + card: game.nexusCards.cards!.hands[actor] ?? null, choice: 'keep', ownRedraws: 0 } }; + } + if (!game.phaseOpening && !game.response && !game.truthtrance) { + if (game.decision?.kind === 'richeseUnbid') return { actor: game.decision.player, + action: { type: 'decision', event: game.richeseBidding!.event, keep: false } }; + if (game.decision?.kind === 'techToken') return { actor: game.decision.player, + action: { type: 'decision', token: game.decision.choices[0] } }; + if (!game.decision && game.richeseAuction) { + const lot = game.richeseAuction; + const actor = lot.method === 'silent' + ? lot.eligible.find(id => !Object.hasOwn(lot.sealed, id)) : lot.active; + assert.ok(actor); + return { actor, action: { type: 'richeseBid', event: lot.event, + amount: lot.method === 'silent' ? 0 : null, allyPayment: 0 } }; + } + } + const next = game.players.some(p => p.faction === 'moritani') ? nextMoritaniNexusStep(game) + : game.players.some(p => p.faction === 'ecaz') ? nextE3NexusEcazStep(game) + : game.expansions.includes('choam') ? nextSingleE2Step(game) + : game.expansions.includes('ix') ? nextSingleNexusE1Step(game) + : nextStrongholdFactionsNativeStep(game); + assert.ok(next, `Original BANK chronology needs its actual ${game.decision?.kind ?? 'sealed plan'} owner`); + return next; +} +export function advanceNexusEmperorBank(game: Game, until: (game: Game) => boolean, + actions?: NexusEmperorBankStep[]): Game { + for (let n = 0; n < 1800; n++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished'); + game = stepNexusEmperorBank(game, nextNexusEmperorBankStep(game), actions); + } + throw Error('Original BANK chronology did not reach its requested boundary'); +} +/** Paid auction prefixes are references, not a second physical card zone. */ +export function nexusEmperorBankInventory(game: Game): void { + const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand), + ...(game.ixSetupCards ?? []), ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []), + ...(game.auction?.cards.slice(game.auction.index + Number(!!game.currentAuctionSale)) ?? [])]; + assert.deepEqual(cards.map(c => c.id).sort(), [...treacheryDeck(game.expansions), + ...(game.players.some(p => p.faction === 'richese') ? richeseCards() : [])].map(c => c.id).sort()); + validateNexusCards(game.nexusCards!.cards!, game.players); + if (game.leaderSkills) validateLeaderSkills(game.leaderSkills, game.players); + if (game.ecazAmbassadors) validateAmbassadors(game.ecazAmbassadors); + if (game.homeworlds) homeworldGameIntegrity(game); + const homes = game.homeworlds ? homeworldForceGroups(homeworldContext(game), game.homeworlds.custody!) : []; + for (const player of game.players) { + const visitors = homes.filter(h => h.native !== player.id).map(h => h.forces[player.id] ?? { normal: 0, elite: 0 }); + assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((n, f) => n + f, 0) + + visitors.reduce((n, f) => n + f.normal + f.elite, 0), 20); + if (player.elites) assert.equal(player.elites.reserves + player.elites.tanks + + Object.values(player.elites.forces).reduce((n, f) => n + f, 0) + visitors.reduce((n, f) => n + f.elite, 0), + player.faction === 'ixians' ? 7 : player.faction === 'emperor' ? 5 : 3); + } +} + +/** Genuine native producer, full first turn, other-seat pact and physical closing draw. + * Only still-undealt Spice/Nexus (and classic starting Treachery) order is selected. */ +export function createNexusEmperorBankFixture(options: NexusEmperorBankOptions = {}): NexusEmperorBankFixture { + const advanced = options.advanced ?? false, profile = options.profile; + const roster = [profile ?? 'harkonnen', 'guild', 'atreides'] as const; + const modules = { advanced, roster, homeworlds: options.homeworlds ?? false, tech: options.tech ?? false }; + let game: Game, initial: Game, afterSetup: Game; + const actions: NexusEmperorBankStep[] = []; + if (profile === 'ixians' || profile === 'tleilaxu') { + const setup = createSingleNexusE1Setup({ ...modules, native: profile, skills: false, + startingEffects: [undefined, undefined, 'truthtrance'] }); + ({ initial, afterSetup } = setup); actions.push(...setup.actions); game = structuredClone(afterSetup); + } else if (profile === 'richese' || profile === 'choam') { + const setup = createSingleE2Setup({ ...modules, native: profile, skills: false }); + ({ initial, afterSetup } = setup); actions.push(...setup.actions); game = structuredClone(afterSetup); + } else if (profile === 'ecaz' || profile === 'moritani') { + const setup = profile === 'ecaz' ? createE3NexusEcazSetup(modules) : createMoritaniNexusSetup(modules); + ({ initial, afterSetup } = setup); actions.push(...setup.actions); game = structuredClone(afterSetup); + } else { + game = createGame('NEXUSEMPERORBANK', newPlayer(roster[0], roster[0], roster[0]), advanced); + for (const faction of roster.slice(1)) joinGame(game, newPlayer(faction, faction, faction)); + game.nexusCards = { cards: null, phase: null }; + for (const [type, enabled] of [['homeworlds', modules.homeworlds], ['techTokens', modules.tech]] as const) + if (enabled) game = stepNexusEmperorBank(game, { actor: game.host, action: { type, enabled } }, actions); + initial = structuredClone(game); + for (const player of game.players) game = stepNexusEmperorBank(game, { actor: player.id, action: { type: 'ready' } }, actions); + game = withClassicDiscoveryNexusLottery(() => initializeNexusGameForAudit(game)); + const prefix: Card[] = []; + for (let n = 0; n < 4; n++) { + const index = game.deck.findIndex(c => c.kind === 'worthless'); assert.ok(index >= 0); + prefix.push(game.deck.splice(index, 1)[0]); + } + game.deck.unshift(...prefix); + game = advanceNexusEmperorBank(game, g => g.status === 'playing', actions); + afterSetup = structuredClone(game); + } + const owner = roster[0]; + orderE3NexusEcazSpice(game); + let firstMentat: NexusEmperorBankTransition | undefined; + for (let n = 0; game.turn === 1 && n < 600; n++) { + const before = structuredClone(game), step = nextNexusEmperorBankStep(game); + game = stepNexusEmperorBank(game, step, actions); + if (game.turn !== before.turn) firstMentat = { before, step, after: structuredClone(game) }; + } + assert.ok(firstMentat); + game = advanceNexusEmperorBank(game, g => g.phase === 1 && clean(g), actions); + orderE3NexusEcazSpice(game, true); + game = advanceNexusEmperorBank(game, g => !!g.nexus && !g.spiceWindow && !g.spiceResolution && clean(g), actions); + const worm = structuredClone(game); + for (const [actor, target] of [['guild', 'atreides'], ['atreides', 'guild']]) + game = stepNexusEmperorBank(game, { actor, action: { type: 'alliance', target } }, actions); + const alliance = structuredClone(game); + game = advanceNexusEmperorBank(game, g => g.nexusCards?.phase?.stage === 'drawing' && clean(g), actions); + const cards = game.nexusCards!.cards!, face = options.nexusCard ?? 'emperor'; + const index = cards.deck.indexOf(face); assert.ok(index >= 0); + cards.deck.unshift(cards.deck.splice(index, 1)[0]); + const before = structuredClone(game), step: NexusEmperorBankStep = { actor: owner, + action: { type: 'nexusCardChoice', turn: game.turn, card: cards.hands[owner] ?? null, choice: 'draw', ownRedraws: 0 } }; + game = stepNexusEmperorBank(game, step, actions); + const draw = { before, step, after: structuredClone(game) }; + assert.equal(game.nexusCards!.cards!.hands[owner], face); + game = advanceNexusEmperorBank(game, g => g.nexusCards?.phase?.stage === 'complete' && clean(g), actions); + const afterClosingDraw = structuredClone(game); + game = advanceNexusEmperorBank(game, g => g.phase === 3 && !!g.auction && !g.richeseAuction && clean(g), actions); + while (game.auction!.active !== owner) + game = stepNexusEmperorBank(game, { actor: game.auction!.active, action: { type: 'passBid' } }, actions); + const beforeBid = structuredClone(game), card = structuredClone(game.auction!.cards[game.auction!.index]); + const price = 2; + game = stepNexusEmperorBank(game, { actor: owner, action: { type: 'bid', amount: price, allyPayment: 0 } }, actions); + game = advanceNexusEmperorBank(game, g => g.decision?.kind === 'auctionPayment', actions); + assert.equal(game.decision!.player, owner); + assert.equal(game.auction!.bid, price); + assert.equal(game.auction!.cards[game.auction!.index].id, card.id); + assert.equal(nexusEmperorBankPlayer(game, owner).hand.some(c => c.id === card.id), false); + const event = emperorNexusPurchaseEvent(game.turn, owner, + game.nexusEmperorPurchaseHistory?.length ?? 0, game.auction!.index, price); + nexusEmperorBankInventory(game); + return { game, owner, event, card, price, wallets: Object.fromEntries(game.players.map(p => [p.id, p.spice])), actions, + chronology: { initial, afterSetup, firstMentat, worm, alliance, draw, afterClosingDraw, beforeBid } }; +} diff --git a/tests/nexus-emperor-bank-profile.test.ts b/tests/nexus-emperor-bank-profile.test.ts new file mode 100644 index 00000000..bcefdf98 --- /dev/null +++ b/tests/nexus-emperor-bank-profile.test.ts @@ -0,0 +1,134 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createGame, newPlayer, type Game } from '../game/engine'; +import { emperorNexusBankPurchaseModeSupported } from '../game/nexus-emperor-purchase'; + +/** Public lobby proof only: an undealt, concealed Nexus face is not yet part of it. */ +function lobby( + factions: Parameters[2][], + expansions: string[], + advanced = false, +): Game { + const g = createGame('BANKMODE', newPlayer(factions[0], factions[0], factions[0]), advanced, expansions); + for (const faction of factions.slice(1)) g.players.push(newPlayer(faction, faction, faction)); + g.order = g.players.map(p => p.id); + g.nexusCards = { cards: null, phase: null }; + return g; +} + +function withTech(g: Game): Game { + g.techTokens = {} as NonNullable; + return g; +} + +function withStrongholds(g: Game): Game { + g.strongholdCards = {} as NonNullable; + return g; +} + +function withSkills(g: Game): Game { + g.leaderSkills = {} as NonNullable; + return g; +} + +function withDiscovery(g: Game): Game { + g.discoveryEnabled = true; + return g; +} + +function withHomeworlds(g: Game): Game { + g.homeworlds = { custody: null }; + return g; +} + +void test('supported native Nexus rosters with the Emperor absent admit the normal bank auction', () => { + const admitted: [string, Game][] = [ + ['bare classic', lobby(['atreides', 'harkonnen', 'fremen'], [])], + ['classic Tech', withTech(lobby(['atreides', 'harkonnen', 'fremen'], []))], + ['classic Advanced Strongholds', withStrongholds(lobby(['atreides', 'harkonnen', 'fremen'], [], true))], + ['classic Skills', withSkills(lobby(['atreides', 'harkonnen', 'fremen'], []))], + ['classic Discovery', withDiscovery(lobby(['atreides', 'harkonnen', 'fremen'], []))], + ['bare paired CHOAM/Richese', lobby(['harkonnen', 'richese', 'choam'], ['choam'])], + ['bare paired Ixians/Tleilaxu', lobby(['atreides', 'ixians', 'tleilaxu'], ['ix'])], + ['paired E1/E2 Tech', withTech(lobby(['harkonnen', 'richese', 'choam'], ['choam']))], + ['paired E1/E2 Advanced Strongholds', withStrongholds(lobby(['harkonnen', 'richese', 'choam'], ['choam'], true))], + ['paired E1/E2 Skills with Tech', withTech(withSkills(lobby(['harkonnen', 'richese', 'choam'], ['choam'])))], + ['paired E1/E2 Homeworlds', withHomeworlds(withTech(lobby(['harkonnen', 'richese', 'choam'], ['choam'])))], + ['paired E1/E2 Discovery', withDiscovery(lobby(['harkonnen', 'richese', 'choam'], ['choam']))], + ['single E1/E2 native', lobby(['harkonnen', 'richese', 'atreides'], ['choam'])], + ['single E1/E2 Skills', withSkills(lobby(['harkonnen', 'richese', 'atreides'], ['choam']))], + ['mixed E1/E2 natives', lobby(['ixians', 'choam', 'atreides'], ['ix', 'choam'])], + ['mixed E1/E2 Skills', withSkills(lobby(['ixians', 'choam', 'atreides'], ['ix', 'choam']))], + ['standalone Ecaz', lobby(['ecaz', 'atreides', 'harkonnen'], ['ecaz'])], + ['standalone Moritani', lobby(['moritani', 'atreides', 'guild'], ['ecaz'])], + ['standalone E3 Skills', withSkills(lobby(['moritani', 'atreides', 'guild'], ['ecaz']))], + ['paired E3 natives', lobby(['ecaz', 'moritani', 'atreides'], ['ecaz'])], + ['paired E3 Skills', withSkills(lobby(['ecaz', 'moritani', 'atreides'], ['ecaz']))], + ['mixed E3 natives', lobby(['ecaz', 'ixians', 'atreides'], ['ecaz', 'ix'])], + ['mixed E3 Skills', withSkills(lobby(['ecaz', 'ixians', 'atreides'], ['ecaz', 'ix']))], + ['mixed E3 Discovery', withDiscovery(lobby(['ecaz', 'ixians', 'atreides'], ['ecaz', 'ix']))], + ]; + for (const [label, g] of admitted) + assert.equal(emperorNexusBankPurchaseModeSupported(g), true, label); +}); + +void test('the previously accepted classic public condition survives unrelated preview aliases', () => { + const aliases: [string, (g: Game) => void][] = [ + ['Advanced preview', g => { g.advancedPreview = true; }], + ['Kull', g => { g.kullPreview = true; }], + ['Nexus Kull', g => { g.nexusKullPreview = true; }], + ['Guild Betrayal', g => { g.guildBetrayalPreview = true; }], + ['Richese Betrayal', g => { g.richeseBetrayalPreview = true; }], + ['Semuta', g => { g.semutaPreview = true; }], + ['Ixian replacement', g => { g.nexusIxianReplacementPreview = true; }], + ['Ixian Betrayal', g => { g.nexusIxianBetrayalPreview = true; }], + ['Harkonnen Betrayal', g => { g.nexusHarkonnenBetrayalPreview = true; }], + ]; + for (const [label, flag] of aliases) { + const classic = lobby(['atreides', 'harkonnen', 'fremen'], []); + flag(classic); + // Prior bank receipts on a bare classic roster keep their original condition. + assert.equal(emperorNexusBankPurchaseModeSupported(classic), true, label); + // The same flag still never opens a new native envelope, even bare paired. + const native = withTech(lobby(['harkonnen', 'richese', 'choam'], ['choam'])); + flag(native); + assert.equal(emperorNexusBankPurchaseModeSupported(native), false, label); + } +}); + +void test('Ecaz variant, the missing module and the seated Emperor stay gated', () => { + const withoutNexus = lobby(['atreides', 'harkonnen', 'fremen'], []); + withoutNexus.nexusCards = undefined; + const sandtrout = lobby(['atreides', 'harkonnen', 'fremen'], []); + sandtrout.sandtrout = true; + const ecazTreachery = lobby(['ecaz', 'atreides', 'harkonnen'], ['ecaz']); + ecazTreachery.ecazTreachery = true; + const blocked: [string, Game][] = [ + ['a missing Nexus module', withoutNexus], + ['Sandtrout', sandtrout], + ['Ecaz Treachery', ecazTreachery], + ['an Emperor seat', lobby(['atreides', 'harkonnen', 'emperor'], [])], + ['an Emperor seat on a native roster', lobby(['harkonnen', 'richese', 'emperor'], ['choam'])], + ]; + for (const [label, g] of blocked) + assert.equal(emperorNexusBankPurchaseModeSupported(g), false, label); +}); + +void test('unsupported rosters and unsupported module envelopes stay gated', () => { + const blocked: [string, Game][] = [ + ['an Ecaz seat on a classic roster', lobby(['atreides', 'ecaz'], [])], + ['a native without its expansion', lobby(['atreides', 'richese'], [])], + ['an E3 expansion without its natives', lobby(['atreides', 'harkonnen'], ['ecaz'])], + ['a native from the other E1/E2 family', lobby(['richese', 'ixians', 'atreides'], ['choam'])], + ['a repeated native faction', lobby(['richese', 'richese', 'choam'], ['choam'])], + ['Tech with only two seats', withTech(lobby(['atreides', 'harkonnen'], []))], + ['Strongholds without Advanced', withStrongholds(lobby(['atreides', 'harkonnen', 'fremen'], []))], + ['Advanced Moritani with Harkonnen', + lobby(['moritani', 'atreides', 'harkonnen'], ['ecaz'], true)], + ['Advanced paired E3 with Harkonnen', + lobby(['ecaz', 'moritani', 'harkonnen'], ['ecaz'], true)], + ['Skills on an E3 classic mix', withSkills(lobby(['ecaz', 'atreides'], []))], + ]; + for (const [label, g] of blocked) + assert.equal(emperorNexusBankPurchaseModeSupported(g), false, label); +}); diff --git a/tests/nexus-emperor-bank-runtime.test.ts b/tests/nexus-emperor-bank-runtime.test.ts new file mode 100644 index 00000000..29b351bd --- /dev/null +++ b/tests/nexus-emperor-bank-runtime.test.ts @@ -0,0 +1,160 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { + advanceNexusEmperorBank, createNexusEmperorBankFixture, nexusEmperorBankInventory, + nexusEmperorBankPlayer, nextNexusEmperorBankStep, stepNexusEmperorBank, + type NexusEmperorBankFixture, type NexusEmperorBankOptions, +} from './fixture-nexus-emperor-bank'; + +function rejectsUnchanged(game: Game, actor: string, action: Action): void { + const original = structuredClone(game); + assert.throws(() => applyAction(game, actor, action)); + assert.deepEqual(game, original, 'Reject before any wallet, card or native lot mutation'); +} +/** Consume actual original sale/bonus and the next original inspection, not a new auction frame. */ +function finishOriginalLot(f: NexusEmperorBankFixture, game: Game): Game { + const index = f.game.auction!.index; + for (let n = 0; n < 100; n++) { + if (game.auction && game.auction.index === index + 1 && + !game.currentAuctionSale && !game.response && !game.phaseOpening && !game.decision) { + assert.deepEqual(viewGame(game, 'atreides').auction!.card, game.auction.cards[game.auction.index]); + assert.equal(nexusEmperorBankPlayer(game, f.owner).hand.length, + nexusEmperorBankPlayer(f.game, f.owner).hand.length + 1 + + Number(nexusEmperorBankPlayer(game, f.owner).faction === 'harkonnen')); + assert.equal(nexusEmperorBankPlayer(game, f.owner).hand.filter(c => c.id === f.card.id).length, 1); + return game; + } + assert.notEqual(game.response?.kind, 'emperorIncome', 'No bank-funded replacement income'); + assert.notEqual(game.decision?.kind, 'auctionPayment', 'The acquired lot cannot ask for payment twice'); + game = stepNexusEmperorBank(game, nextNexusEmperorBankStep(game)); + } + throw Error('Original BANK lot did not reach its native next lot'); +} + +const natives = ['ixians', 'tleilaxu', 'richese', 'choam', 'ecaz', 'moritani'] as const; +const scenarios: NexusEmperorBankOptions[] = [ + { advanced: false }, { advanced: true }, + ...natives.flatMap(profile => [false, true].map(advanced => ({ profile, advanced }))), + { advanced: true, homeworlds: true, tech: true }, + ...(['ixians', 'richese', 'ecaz'] as const).map(profile => + ({ profile, advanced: true, homeworlds: true, tech: true })), +]; + +for (const scenario of scenarios) for (const use of [false, true]) void test( + `${scenario.advanced ? 'Advanced' : 'Basic'} ${scenario.profile ?? 'classic'} original BANK${scenario.homeworlds ? ' Homeworld/Tech' : ''}: ${use ? 'Use retains the positive bid' : 'Pay debits the original bid'}`, () => { + const f = createNexusEmperorBankFixture(scenario), original = structuredClone(f.game); + const player = nexusEmperorBankPlayer(f.game, f.owner), offer = viewGame(f.game, f.owner).nexusEmperorSecretAlly; + assert.ok(offer?.purchase && !offer.purchase.blocked); + assert.ok(f.price > 0 && player.spice >= f.price); + assert.equal(player.ally, null); + assert.ok(!f.game.players.some(p => p.faction === 'emperor')); + assert.deepEqual(viewGame(f.game, 'atreides').auction!.card, f.card); + const expectedBonus = player.faction === 'harkonnen' ? structuredClone(f.game.deck[0]) : null; + const action: Action = use ? { type: 'nexusEmperorPurchase', event: offer.event } + : { type: 'decision', karama: false }; + let game = stepNexusEmperorBank(f.game, { actor: f.owner, action }); + assert.deepEqual(f.game, original); + assert.deepEqual(nexusEmperorBankPlayer(game, f.owner).hand.find(c => c.id === f.card.id), f.card); + assert.equal(game.nexusCards!.cards!.hands[f.owner], use ? null : 'emperor'); + assert.equal(game.nexusCards!.cards!.discard.filter(c => c === 'emperor').length, use ? 1 : 0); + game = finishOriginalLot(f, game); + for (const p of game.players) + assert.equal(p.spice, f.wallets[p.id] - (p.id === f.owner && !use ? f.price : 0), + 'Only ordinary Pay debits the buyer; no other seat receives bank money'); + if (expectedBonus) assert.deepEqual(nexusEmperorBankPlayer(game, f.owner).hand.find(c => c.id === expectedBonus.id), expectedBonus); + assert.deepEqual(game.techTokens, original.techTokens); + assert.deepEqual(game.homeworlds, original.homeworlds); + if (scenario.profile === 'richese') { + assert.ok(f.game.richeseRemoved!.length > 0, 'The original unbid cache lot was resolved before the bank pool'); + assert.equal(f.game.richeseBidding!.stage, 'normal'); + assert.deepEqual(game.richeseCache, original.richeseCache); + assert.deepEqual(game.richeseRemoved, original.richeseRemoved); + } + nexusEmperorBankInventory(game); + if (use) rejectsUnchanged(game, f.owner, action); + }, +); + +for (const profile of natives) void test(`${profile}: a real irrelevant closing face still pauses at the same public payment boundary`, () => { + const held = createNexusEmperorBankFixture({ profile }); + const irrelevant = createNexusEmperorBankFixture({ profile, nexusCard: 'guild' }); + assert.deepEqual(irrelevant.game.decision, held.game.decision); + assert.equal(viewGame(irrelevant.game, irrelevant.owner).nexusEmperorSecretAlly, null); + rejectsUnchanged(irrelevant.game, irrelevant.owner, { type: 'nexusEmperorPurchase', event: irrelevant.event }); + const game = finishOriginalLot(irrelevant, stepNexusEmperorBank(irrelevant.game, + { actor: irrelevant.owner, action: { type: 'decision', karama: false } })); + assert.equal(nexusEmperorBankPlayer(game, irrelevant.owner).spice, irrelevant.wallets[irrelevant.owner] - irrelevant.price); + assert.equal(game.nexusCards!.cards!.hands[irrelevant.owner], 'guild'); + nexusEmperorBankInventory(game); +}); + +void test('native BANK purchase rejects foreign, stale and extra-cost inputs before changing original custody', () => { + const f = createNexusEmperorBankFixture({ profile: 'ixians', advanced: true }); + for (const p of f.game.players.filter(p => p.id !== f.owner)) + rejectsUnchanged(f.game, p.id, { type: 'nexusEmperorPurchase', event: f.event }); + for (const action of [ + { type: 'nexusEmperorPurchase', event: 'stale' }, + { type: 'nexusEmperorPurchase', event: f.event, price: 0 }, + { type: 'nexusEmperorPurchase', event: f.event, allyPayment: 1 }, + { type: 'nexusEmperorPurchase', event: f.event, card: f.card.id }, + { type: 'nexusEmperorPurchase', event: f.event, use: true }, + ]) rejectsUnchanged(f.game, f.owner, action); + const wallet = nexusEmperorBankPlayer(f.chronology.beforeBid, f.owner).spice; + rejectsUnchanged(f.chronology.beforeBid, f.owner, { type: 'bid', amount: wallet + 1, allyPayment: 0 }); + nexusEmperorBankInventory(f.game); +}); + +void test('a genuinely setup-dealt Truthtrance takes priority over the original native bank purchase', () => { + const f = createNexusEmperorBankFixture({ profile: 'tleilaxu', advanced: true }); + const truth = nexusEmperorBankPlayer(f.game, 'atreides').hand.find(c => c.effect === 'truthtrance'); + assert.ok(truth, 'Truthtrance must come from the original setup deal'); + let game = stepNexusEmperorBank(f.game, { actor: 'atreides', action: { type: 'card', card: truth.id } }); + assert.equal(game.truthtrance!.stage, 'priority'); + rejectsUnchanged(game, f.owner, { type: 'nexusEmperorPurchase', event: f.event }); + while (game.truthtrance?.stage === 'priority') { + const actor = game.players.find(p => !game.truthtrance!.passed.includes(p.id))!.id; + game = stepNexusEmperorBank(game, { actor, action: { type: 'truthPass' } }); + } + const name = nexusEmperorBankPlayer(game, f.owner).hand[0].name; + game = stepNexusEmperorBank(game, { actor: 'atreides', action: { type: 'truthAsk', + question: { kind: 'fact', target: f.owner, fact: { kind: 'hand', name } } } }); + assert.equal(viewGame(game, f.owner).truthAnswer, 'yes'); + game = stepNexusEmperorBank(game, { actor: f.owner, action: { type: 'truthAnswer', answer: 'yes' } }); + const offer = viewGame(game, f.owner).nexusEmperorSecretAlly; + assert.ok(offer?.purchase && !offer.purchase.blocked); + game = finishOriginalLot(f, stepNexusEmperorBank(game, { actor: f.owner, + action: { type: 'nexusEmperorPurchase', event: offer.event } })); + assert.equal(nexusEmperorBankPlayer(game, f.owner).spice, f.wallets[f.owner]); + assert.equal(game.discard.filter(c => c.id === truth.id).length, 1); + nexusEmperorBankInventory(game); +}); + +void test('minimal Easy policy legally completes the actual native bank boundary for both private faces', () => { + for (const nexusCard of ['emperor', 'guild'] as const) { + const f = createNexusEmperorBankFixture({ profile: 'choam', advanced: true, nexusCard }); + 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 = finishOriginalLot(f, stepNexusEmperorBank(f.game, { actor: f.owner, action })); + assert.equal(nexusEmperorBankPlayer(game, f.owner).spice, f.wallets[f.owner] - (nexusCard === 'emperor' ? 0 : f.price)); + assert.equal(game.nexusCards!.cards!.hands[f.owner], nexusCard === 'emperor' ? null : 'guild'); + nexusEmperorBankInventory(game); + } +}); + +void test('native bank evidence stays historical after a real later purchase changes wallet and hand', () => { + const f = createNexusEmperorBankFixture({ profile: 'richese', advanced: true, homeworlds: true, tech: true }); + let game = finishOriginalLot(f, stepNexusEmperorBank(f.game, + { actor: f.owner, action: { type: 'nexusEmperorPurchase', event: f.event } })); + while (game.auction!.active !== f.owner) + game = stepNexusEmperorBank(game, { actor: game.auction!.active, action: { type: 'passBid' } }); + const card = structuredClone(game.auction!.cards[game.auction!.index]); + game = stepNexusEmperorBank(game, { actor: f.owner, action: { type: 'bid', amount: 1, allyPayment: 0 } }); + game = advanceNexusEmperorBank(game, g => g.decision?.kind === 'auctionPayment'); + game = stepNexusEmperorBank(game, { actor: f.owner, action: { type: 'decision', karama: false } }); + assert.equal(nexusEmperorBankPlayer(game, f.owner).spice, f.wallets[f.owner] - 1); + assert.deepEqual(nexusEmperorBankPlayer(game, f.owner).hand.find(c => c.id === card.id), card); + nexusEmperorBankInventory(game); +}); diff --git a/tests/nexus-emperor-extra-source.test.ts b/tests/nexus-emperor-extra-source.test.ts index ac018bd9..839fd221 100644 --- a/tests/nexus-emperor-extra-source.test.ts +++ b/tests/nexus-emperor-extra-source.test.ts @@ -10,7 +10,6 @@ import { createHomeworldCustody } from '../game/homeworld-custody'; import { createEmperorNexusRevival, emperorNexusEvent, - emperorNexusModeSupported, emperorNexusPools, emperorNexusSignature, quoteEmperorNexusRevival, @@ -97,7 +96,6 @@ void test('native typed Tanks determine exact-three choices and Ix Cyborgs are n for (const advanced of [false, true]) { const ix = sample('ixians', advanced); ix.players[0].elites!.revived = 4; - assert.equal(emperorNexusModeSupported(ix), false); assert.equal(quoteEmperorNexusRevival(ix, 'owner').blocked, null); assert.deepEqual(quoteEmperorNexusRevival(ix, 'owner').eliteOptions, [0, 1, 2, 3]); assert.equal(createEmperorNexusRevival(ix, 'owner', 3).elite, 3); @@ -129,7 +127,6 @@ void test('original native modules allow revival independently of classic purcha const before = structuredClone(g); createEmperorNexusRevival(g, 'owner', 0); assert.deepEqual(g, before); - if (faction !== 'atreides') assert.equal(emperorNexusModeSupported(g), false); g.homeworlds = { custody: createHomeworldCustody({ advanced: g.advanced, players: g.players.map(p => ({