diff --git a/components/nexus-ixian-replacement.tsx b/components/nexus-ixian-replacement.tsx index edddb5c4..60ddf35f 100644 --- a/components/nexus-ixian-replacement.tsx +++ b/components/nexus-ixian-replacement.tsx @@ -39,6 +39,12 @@ export function NexusIxianReplacement({ game, act, busy }: { Only the buyer can choose. The original payment stays paid; this is not another purchase or auction.{' '} Purchased-card timing and preview limits

+ {offer.origin === 'cache' && ( +

+ Provisional cache scope: this independent absent-Ixian Nexus grant applies to a genuine + cache purchase. The separate exclusion for the native Ixian ally advantage is unchanged. +

+ )} {buyer?.faction === 'harkonnen' && (

Provisional Harkonnen order: choose this replacement before the original income and diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 46e9a777..b0888312 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -30,24 +30,29 @@ Rules implementation continues; comprehensive assurance and administration remain deferred until all rules are implemented. **Ixian Nexus purchased-card interaction wave — 16 October.** -Normal purchased-card replacement now composes: +Purchased-card replacement now composes: - successful native BG Karama conversion, after its original counter; - Harkonnen's original cancelable bonus and eight-card limit under a visibly provisional replacement → income → bonus order matching the native ally path; - original Tleilaxu/Ix47 setup and native purchases in Basic **and Advanced**; -- native Richese/classics normal purchases with CHOAM35 and separate cache, - using selected-family card validation without enabling special-origin sales. +- native Richese/classics normal purchases with CHOAM35 and separate cache; +- actual positive native cache purchases in both modes and Black Market in + Advanced, using the real sold lot and original price/continuation. -Latest affected seven-file65/65, eight original Richese runtime paths, -sixteen legal policy actions and two390px original-source journeys pass. -They preserve real payment/income, unchanged cache and one next-lot advance; -real final-lot/cache continuation retains the cache-origin gate. Types/lint -and actual CLI help pass. Two tool-only stale-node races were recovered -without replay; no application error or overflow. Earlier evidence and -explicit policies remain in the [canonical contract](NEXUS_IXIAN_REPLACEMENT_RULES.md). -All five facets stay Partial. No saved room, human server, D1, deployment or -public gate changed; full modules and final assurance remain separate. -Current source anchors and21 topic evidence paths resolve. +Cache provisionally uses the independent-Nexus grant under the standing +choose-recommended priority; the native ally cache exclusion is unchanged. +Actual controls show that policy. Latest nine-file98/98, types/lint/build, +six original native runtime paths, twelve legal policy actions and two390px +journeys pass. Unbid owner retention is not a purchase. The original seller +credit and phase/declaration suffix finish once, with no synthetic normal +row or extra payment. Source/receipt v1 shapes and normal parents remain. +Earlier evidence and exact policies are in the +[canonical contract](NEXUS_IXIAN_REPLACEMENT_RULES.md). +All five facets stay Partial. Direct special-Karama sources, other modules +and final assurance remain separate. No human server, saved room, D1, +deployment or public gate changed. +Current source anchors and23 topic evidence paths resolve; all five facets +remain Partial. **Semuta connects both physical Shrine roles — 16 October.** Fresh private `semuta --discoveries` setup retains original Richese/CHOAM, diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 70af601e..1cec8a42 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,33 @@ # Dune implementation status +## 16 October 2026 — Genuine Richese cache and Black Market Ixian replacement + +Connected the actual positive sold native lot after buyer debit/card delivery +but before original eligible credit/continuation. A native lot keeps index0 +in unchanged v1 receipts; normal parent JSON is unchanged. Original shared +quotes bind native authority; no normal row, price or Game is manufactured. +Cache uses the visibly provisional independent-Nexus interpretation under +choose-recommended, leaving the printed native Ixian ally exclusion intact. +Cache supports both modes; Black Market remains Advanced-only. Unbid owner +retention is not a purchase; direct special-Karama sources stay separate. + +Affected nine-file98/98, types/lint and production build pass with the +module-register/large-chunk/Unknown-route warnings. Six original runtime +paths, twelve legal policy actions and two actual390px journeys pass: +cache Residual Poison→Artillery, single buyer8→7/seller5→6 and Revival; +Advanced Black Market Ornithopter Keep, buyer12→11/seller5→6 and original +first/last declaration controls. Emperor income stays unchanged in these +specific other-buyer scenarios. No overflow or browser/application error. + +The fixture now resolves actual responses before bidding; no native window +was suppressed. The obsolete exact DTO pin was deleted while meaningful +rival-private-card/eligibility assertions remain. A callback annotation is +type-only. No human server, D1, saved room, deployment/public gate or new +broad/HTTP/saved-game assurance changed/run. +[Source, selected interpretation and proof](NEXUS_IXIAN_REPLACEMENT_RULES.md#genuine-cache-and-black-market-purchases--16-october-2026). +Five current source anchors and23 topic evidence paths resolve; all five +facets remain Partial without full-family or deployed certification. + ## 16 October 2026 — Native Richese normal-purchase Ixian replacement Admitted fresh Richese/classics, exact CHOAM35 plus original cache, both modes diff --git a/docs/NEXUS_CARD_RULES.md b/docs/NEXUS_CARD_RULES.md index da1d72b7..49b18d20 100644 --- a/docs/NEXUS_CARD_RULES.md +++ b/docs/NEXUS_CARD_RULES.md @@ -1540,7 +1540,7 @@ The private holder offer contains CHOAM's name; it includes the hand count only Later focused source contracts cover [Emperor strength, purchases and revival](NEXUS_EMPEROR_RULES.md), [Ixian purchased-card replacement](NEXUS_IXIAN_REPLACEMENT_RULES.md), [Fremen worm and revival effects](NEXUS_FREMEN_RULES.md), and [Richese's separate shipment and auction panels](NEXUS_RICHESE_RULES.md). These audits distinguish implemented work from remaining source/timing questions; current runtime evidence is tracked in [implementation status](IMPLEMENTATION_STATUS.md). Bounded Richese auction and Guild shipment Betrayal checkpoints have their own direct local verification below; neither inherits certification of its complete faction, Nexus family or live deployment from earlier evidence. -The [bounded Ixian Secret Ally replacement integration](NEXUS_IXIAN_REPLACEMENT_RULES.md#bounded-local-runtime-contract) connects normal paid/Karama purchases in fresh classic, native Tleilaxu or native Richese profiles in Basic/Advanced. Richese retains exact CHOAM35 plus separate cache; selected catalogue membership does not enable cache/Black Market/special-origin replacement. Successful native BG conversion qualifies after its counter. Harkonnen's provisional order remains replacement, income, cancelable bonus, with no second bonus or eight-card slot. Original settlement/full-hand/same-card semantics remain. Neutral timing/order are product composition, not publisher policy; the adopted core PDF adds no Nexus face or priority ruling. Other modules and full/recovery/deployed acceptance remain open. +The [bounded Ixian Secret Ally replacement integration](NEXUS_IXIAN_REPLACEMENT_RULES.md#bounded-local-runtime-contract) connects normal purchases in classic/Tleilaxu/Richese profiles, positive Richese cache purchases in Basic/Advanced and genuine Black Market purchases in Advanced. Actual delivered card, native sold lot, original price and eligible recipient remain bound; no normal row, payment or Game is fabricated/replayed. Cache's independent-Nexus scope is visibly provisional and does not alter the printed native Ixian ally exclusion. BG's native counter and Harkonnen's provisional bonus order remain. Unbid keeps/retains supply no purchase. Direct special-Karama sources, other modules, universal priority and full/recovery/deployed acceptance stay separate; the adopted core PDF supplies no Nexus face or new publisher ruling. **Atreides is the first complete effect family to implement.** It has independent rulebook and physical-face evidence, and the engine already has private plan-element inspection. The Nexus effect needs its own cause and receipt rather than temporarily changing the inspecting faction. The second element must differ from the already inspected element. Preserve the original disclosed value if an allowed later battle edit occurs; follow the existing plan commitment rules instead of refreshing an old observation. The card specifies an element, not the entire hand or battle plan. A weapon/defense request uses actual played-card classification, including the published Reinforcements/Harass exclusions, rather than leaking a card merely because it occupies that slot. diff --git a/docs/NEXUS_CARD_RUNTIME.md b/docs/NEXUS_CARD_RUNTIME.md index b9e8ff1b..335672e5 100644 --- a/docs/NEXUS_CARD_RUNTIME.md +++ b/docs/NEXUS_CARD_RUNTIME.md @@ -31,7 +31,7 @@ See the [source protocol](NEXUS_CARD_RULES.md#bounded-ixian-betrayal-source-prot and [current local contract](#bounded-ixian-betrayal-local-runtime--1-october-2026); the historical lifecycle counts below do not verify this later feature. -**Later Ixian Secret Ally replacement prototype:** [NEXUS_IXIAN_REPLACEMENT_RULES.md](NEXUS_IXIAN_REPLACEMENT_RULES.md#bounded-local-runtime-contract) records the fresh local `ixian-replacement` profile: exact normal purchased card, discard before private deck draw, full-hand/same-card behavior and unchanged settlement once. Successful native BG conversion purchases now qualify after their native counter. Harkonnen uses the visibly provisional native replacement-before-income/bonus order, preserving its original cancelable bonus and capacity. Cache/Black Market/special origins and combined modules remain guarded. Neutral buyer timing and provisional ordering are product composition, not publisher priority. Historical native/recovery/CLI evidence and the newer original-source rule/control proof remain Partial; no public/module gate opens. +**Later Ixian Secret Ally replacement prototype:** [NEXUS_IXIAN_REPLACEMENT_RULES.md](NEXUS_IXIAN_REPLACEMENT_RULES.md#bounded-local-runtime-contract) connects genuine normal purchases, positive Richese cache purchases in Basic/Advanced and Black Market purchases in Advanced, preserving original settlement and discard-before-draw/full-hand/same-card behavior. Native BG counters resolve first; Harkonnen's bonus remains under its visible provisional order. Cache uses a visible provisional independent-Nexus interpretation, leaving the actual native ally's cache exclusion intact. Unbid retention is not a purchase. Direct Richese special-Karama sources and other modules remain separate. Neutral timing and scope/order interpretations are product composition, not publisher priority. Source-qualified prototype evidence remains Partial; no public/module gate opens. The later [Emperor Secret Ally revival prototype](NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md) adds exactly three additional free force returns, separate ordinary allowances, private controls and saved continuation. Its purchase alternative and full module acceptance remain pending. diff --git a/docs/NEXUS_IXIAN_REPLACEMENT_RULES.md b/docs/NEXUS_IXIAN_REPLACEMENT_RULES.md index bf8be031..3fce9570 100644 --- a/docs/NEXUS_IXIAN_REPLACEMENT_RULES.md +++ b/docs/NEXUS_IXIAN_REPLACEMENT_RULES.md @@ -2,14 +2,67 @@ Source audit: 2026-09-10. Bounded local prototype integration and verification: 2026-10-01. **Partial development verified**, not complete Ixian/Nexus family or expansion acceptance. This document records the source and runtime boundary of the fresh `ixian-replacement` profile. Existing [Nexus release gates](NEXUS_CARD_RUNTIME.md) remain in force. +## Genuine cache and Black Market purchases — 16 October 2026 + +The existing fresh Richese/CHOAM profile now binds actual positive sold +cache lots in Basic/Advanced and actual Black Market lots in Advanced. +Basic Black Market is not granted. The card is already delivered and its +original bid debited before the independent purchased-card choice; Use/Keep +then resumes the same eligible recipient, income, bonus and lot continuation. +Unbid owner keeps/retains are not purchases and never create this choice. + +**Selected provisional cache scope:** under the standing choose-recommended +instruction, use the photographed Nexus face's independent grant for a +genuine cache purchase. It does not name the actual native Ixian alliance +advantage or repeat E2's cache exclusion. The interface labels this literal +composition provisional; no new publisher FAQ or explicit user-selected +cache ruling is claimed. Native Ixian allies still cannot replace a cache +purchase. Direct Richese special-Karama acquisitions remain a separate +implementation gap, not a ruling that they are not purchases. + +The source uses the real sold native lot, owner, target and quoted price; +no normal row or replacement Game is fabricated. Its single lot has index0 +in the existing v1 receipt. Normal source/receipt shapes and parent strings +remain unchanged; a live native lot enters the parent only when present. +Shared native continuation quotes retain original authority. The projected +origin is public source category only, not a new price or card disclosure. + +Affected nine-file **98/98**, types/lint and production build pass, retaining +module-register, large-chunk and Unknown-route warnings. Six original native +Use/Keep runtime paths and twelve legal policy actions cover cache in both +modes and Advanced Black Market. Eleven actual native scenario cases cover +already-paid timing, both outcomes and genuine unbid retention; pure source +cases reject mismatched/open/unbid/competing lots and Basic Black Market. +The fixture now respects real response priority before bidding. An obsolete +exact projection-object pin was removed while semantic rival privacy checks +remain; a callback typing annotation changes no behavior. + +Two styled390×844 original-source journeys pass. Basic cache Use exchanges +Residual Poison for the real deck-top Artillery, spends Nexus, retains the +single price8→7 and seller5→6, and reaches Revival. Advanced Black Market +Keep retains purchased Ornithopter and Nexus, price12→11/seller5→6, then +returns to Richese's actual first/last declaration controls. Emperor income +is unchanged in both specific other-buyer scenarios. Width/scroll390 and no +browser/application errors. Current CLI help names exact mode/scope bounds. + +Only an owned memory-only loopback host ran. No human server, D1, saved room, +deployment or public gate changed. Proof: +`/tmp/dune-richese-seller-ixian-runtime.json`, +`/tmp/dune-richese-seller-ixian-ui-proof.json`. No broad suite or new HTTP/ +saved-game assurance ran; all five checklist facets remain Partial. + + +Five current source anchors and23 topic evidence paths resolve without +reference parse diagnostics; all five checklist facets remain Partial. + ## Native Richese normal purchases — 16 October 2026 -The fresh private profile additionally admits native Richese plus classic -opponents in Basic/Advanced with exact CHOAM35, its separate original -ten-card cache and Nexus alone. Original cache auctions and qualifying -Nexus acquisition precede any normal purchased-card choice. Only a genuine -**normal-auction** purchase is connected; cache, Black Market and special -purchase replacement stay excluded under their separate source boundaries. +This earlier normal-purchase checkpoint admitted Richese plus classics, +Basic/Advanced, with CHOAM35, the separate original cache and Nexus alone. +Original cache auctions and qualifying Nexus acquisition preceded each +normal choice. Genuine cache and Advanced Black Market replacement were +added afterward in the composition above; direct special purchases remain +separate. Live card validation now receives selected `expansions` and native `richese` presence. Exact base, Ix, CHOAM and cache membership is precomputed from the @@ -195,7 +248,7 @@ An ordinary Karama acquisition is described by November's FAQ as purchasing with E2 explicitly excludes a direct Richese cache purchase from the **Ixian ally** replacement, while allowing Black Market purchases, including a Richese-family card. It also awards Harkonnen's normal bonus for both sale kinds. The restriction follows purchase origin, not card family. [E2 pp.10–11](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf#page=10) -That question names an ally of Ixians. The later Nexus face independently grants the operation when Ixians are absent and does not name the native alliance advantage or repeat the cache exclusion. The literal independent-grant reading includes genuine cache purchases during Bidding. Extending the older exception to the new card is an alternative interpretation; no retrieved primary clarification selects it. Do not silently reuse an existing native `ixAllyCard` origin filter as though it settled this distinction. The same analysis matters for Richese's genuine special-Karama cache purchase during Bidding; its other timing and hand-cap boundaries remain in the [acquisition audit](RICHESE_ACQUISITION_RULES.md). +That question names an ally of Ixians. The later Nexus face independently grants the operation when Ixians are absent and does not name the native alliance advantage or repeat the cache exclusion. The selected 16 October provisional interpretation therefore includes genuine cache purchases during Bidding under the standing choose-recommended instruction. Extending the older exception is an alternative reading, but no retrieved primary clarification selects it; the actual native `ixAllyCard` cache filter remains unchanged, not imported as Nexus authority. This interpretation is visibly labelled product composition, not a publisher ruling. The same source analysis applies to genuine special-Karama purchases during Bidding, whose direct source is not yet connected; other timing/cap boundaries remain in the [acquisition audit](RICHESE_ACQUISITION_RULES.md). November's Karama table expressly permits stopping the native Ixian ally from discarding a purchased card. For this separately worded Nexus grant, Ixians are absent and the holder is not using a present faction's native or actual alliance advantage. Treating it as a direct card effect without that native cancellation window is the supported category composition used for Atreides Secret Ally; it is not an express general Nexus-immunity ruling. Unlike Bene Gesserit Secret Ally, this face does not say to use a named faction advantage. [FAQ p.7](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=7), [Face](https://boardgamegeek.com/image/7767032/dune-ecaz-and-moritani) @@ -222,8 +275,8 @@ and never add seats to an admitted game or fabricate Nexus hands. ### Connected purchase and choice -1. Only an actual normal-auction purchase is connected: paid spice or genuine Karama acquisition/payment, including direct acquisition before a positive bid. Native Advanced BG Worthless conversion must first complete its existing response; successful free purchases then get the same replacement choice. A canceled direct conversion makes no purchase; canceled winning payment follows its original funded spice-payment recovery and, if purchased, supplies a paid receipt. The original source, purchased physical card and native sale continuation remain bound after genuine acquisition. Gifts, bonus draws, Technology swaps, old hand cards and historical purchase IDs cannot create an opportunity. -2. Harkonnen buyers use the visibly provisional replacement-before-income/bonus order above, preserving the original cancelable bonus and eight-card limit. Richese cache, Black Market and special-acquisition origins remain guarded under their separate independent Nexus scope; the native `ixAllyCard` origin filter is not imported as authority. +1. Connect actual normal-auction purchases, including successful native BG conversion after its original counter, and actual positive sold Richese cache lots (Basic/Advanced) or Black Market lots (Advanced only). Native buyer debit and card delivery precede the source-bound choice; original recipient/income/bonus/lot continuation follows once. Unbid keeps/retains, gifts, bonus draws, Technology swaps, old hand cards and historical IDs supply no purchase. Direct Richese special-Karama acquisition remains separate. +2. Cache uses the visibly provisional independent-Nexus interpretation above, not the actual native ally advantage excluded by E2. Harkonnen retains the visibly provisional replacement-before-income/bonus order, original cancelable bonus and eight-card limit. Neither selected composition is a located publisher timing FAQ. 3. Every publicly possible supported buyer receives the same event and public buyer identity, regardless of its secret Nexus face. Only that buyer receives Pass and the exact already-owned purchased card for inspection; only a truly eligible unallied holder of the physical Ixian Nexus receives Use. Opponents receive neither the purchased/replacement face nor private price, receipt, deck order or eligibility reason. The buyer may inspect its own actually held Nexus through the existing authorized inspector; the neutral prompt grants no rival entitlement. 4. **Pass** retains the purchased card and Nexus, closes this opportunity and continues the original sale once. **Use** spends that singleton Nexus once, discards exactly the just-purchased physical Treachery Card, then draws the actual next deck card privately. There is no card selector, arbitrary hand exchange, new Karama counter, refund, repayment or re-auction. 5. The discard opens a full legal hand's slot before the draw. An exhausted deck recycles eligible Treachery discards, including that just-discarded card; drawing the same physical card again is legal. The unsold auction row is not the draw deck. Earlier Atreides auction inspection does not grant a new peek of this replacement. @@ -340,4 +393,4 @@ custody/history), `tests/nexus-ixian-replacement-engine.test.ts` and genuine `tests/prototype-room.test.ts` (fresh profile and exact-version backed-up CLI). This evidence is not complete family/module or deployed acceptance. -Independent Richese cache/Black Market/special scope, expansion-card discard reactions, Semuta/Ecaz priority and anytime interleaving remain outside this profile. Harkonnen's connected replacement-before-bonus sequence is explicitly provisional, not a settled publisher priority. Full Ixian/Nexus families, combined modules, public starts, complete games and live release acceptance remain separate gates. +Direct Richese special-Karama sources, expansion-card discard reactions, Semuta/Ecaz priority and anytime interleaving remain outside this profile. Cache's independent-grant scope and Harkonnen's replacement-before-bonus order are explicitly provisional, not settled publisher priority. Full Ixian/Nexus families, combined modules, public starts, complete games and live release acceptance remain separate gates. diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 2147941f..fc3c216e 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -694,7 +694,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP | Advanced Richese ally contribution | The authorized supplied Advanced Richese Bidding clause routes Richese's contribution to its ally's cache/Black Market purchase to Emperor or bank, even when Emperor buys. Preserve the original donor leg separately from buyer-to-Richese credit; cancellation redirects only the Emperor leg without paying twice. Basic retains R2's earlier receipt. Native Banker counts only final bank legs; exhausted-cache counts, positive Black Market self-bidding and Technology custody remain unresolved. | [Source distinction and recipient contract](RICHESE_AUCTION_RULES.md#authorized-advanced-contribution-recipients--4-october-2026) | | Richese Nexus Betrayal auctions | Pause at a valid original prepayment quote. Public self-cache veto discards the exact card without debit/acquisition; another buyer's positive cache/Black Market invoice keeps price/contributions/delivery and earned Harkonnen bonus while overriding the recipient to bank. All publicly possible unallied other-faction held-Nexus seats acknowledge uniformly; private identity controls only own Use. This neutral protocol is an application choice, not publisher timing. Normal hidden-family self-purchases, special-Karama veto and unsupported overlays remain excluded; no native Karama exception certifies them. Bounded engine/SQL/bot/phone scenarios are verified locally; broader verification remains Partial and public starts/full Nexus gates stay closed. | [Printed face, profile, API and saved continuation](NEXUS_RICHESE_RULES.md#bounded-betrayal-auction-contract--30-september-2026), [common privacy protocol](NEXUS_CARD_RULES.md#richese-betrayal-prepayment-protocol), [auction source boundaries](RICHESE_AUCTION_RULES.md) | | Guild Nexus Betrayal shipment payment | Replace one original funded positive fee in full, including own pre-funded payment and the printed full occupied-Junction override; preserve original payer/contributions, consume already-debited donor escrow once and deliver the original physical shipment once. Native applicable stop/rate responses precede the bounded gate; zero/stopped shipments supply no award or Nexus cost. Public possible held-Nexus seats acknowledge uniformly; private Use changes no public price/source disclosure. Fresh classic/native-Guild/Nexus with optional genuinely seeded Homeworlds only; unsupported native Cunning/Richese shipment overlays block before card/parent commitment, and restored transport independently requires the native Guild grant. Bounded engine/SQL/bot/CLI/phone custody and refresh are locally verified; no new priority/immunity, ordinary occupation/rounding ruling or extra shipment. Broad/public/live gates remain closed. | [Printed source and bounded contract](NEXUS_GUILD_RULES.md#nexus-guild-betrayal), [common protocol](NEXUS_CARD_RULES.md#guild-betrayal-funded-payment-protocol), [internal checklist](../game/reference.ts) | -| Ixian Nexus Secret Ally replacement | Fresh Basic/Advanced classics/base33, Tleilaxu/classics Ix47, or Richese/classics CHOAM35 plus original cache, with Ixians absent and Nexus alone. Replace only a normal purchased card with the actual deck top and preserve settlement. Native BG counter resolves first; paid recovery supplies a paid receipt. Harkonnen uses provisional replacement → income → cancelable bonus, with no second bonus or capacity gain. Selected card families and native cache membership remain exact. Neutral timing/order are product policy, not publisher priority. Cache/Black Market/special origins and other overlays stay guarded; no public start or retrofit. | [Base contract](NEXUS_IXIAN_REPLACEMENT_RULES.md#bounded-local-runtime-contract), [native Richese scope](NEXUS_IXIAN_REPLACEMENT_RULES.md#native-richese-normal-purchases--16-october-2026), [BG composition](NEXUS_IXIAN_REPLACEMENT_RULES.md#native-bg-converted-purchases--16-october-2026), [provisional Harkonnen order](NEXUS_IXIAN_REPLACEMENT_RULES.md#harkonnen-purchase-and-bonus-composition--16-october-2026) | +| Ixian Nexus Secret Ally replacement | Fresh classic/Tleilaxu/Richese profiles, Ixians absent, unallied buyer and Nexus alone. Normal purchases and actual positive Richese cache sales (Basic/Advanced) or Black Market sales (Advanced) bind the delivered card, real sold lot, original price and native continuation. Cache provisionally uses the independent-Nexus grant under choose-recommended, not the printed native ally advantage; its exclusion remains intact and is visibly explained. Unbid retention is not a purchase. BG's counter and Harkonnen's provisional bonus/cap order remain. No fabricated normal row or extra payment. Direct special-Karama sources/other modules and full acceptance stay separate. | [Current source contract](NEXUS_IXIAN_REPLACEMENT_RULES.md#bounded-local-runtime-contract), [selected cache interpretation and native-origin proof](NEXUS_IXIAN_REPLACEMENT_RULES.md#genuine-cache-and-black-market-purchases--16-october-2026) | | Ixian Nexus Betrayal: Bidding or Technology | Fresh native-Ixian/classic-or-Tleilaxu Basic/Advanced local `ixian-betrayal` uses Ix47 Treachery and Nexus alone, with no initial Nexus deal or old-save retrofit. Resolve actual native Karama/BG counters first; a native denial opens no Nexus gate or cost. After all native passes, uniform publicly possible unallied non-Ixian held-Nexus seats acknowledge before the native effect; concealed face governs only private Use. These are bounded privacy/ordering inferences, not publisher universal priority. One physical Ixian Nexus denies one current Bidding attempt (normal count, no Ixian inspection/return) or one Advanced Technology declaration (both card locations preserved, attempt remains used); all-pass allows the original effect and suffix once. No whole-phase ban, completed undo, invented direct-Nexus Karama counter or blanket immunity. Richese special-lot exchange, wider priority/modules, complete games, strategy and public/live release remain open; verification is Planned pending frozen direct evidence. | [Photographed face and native source protocol](NEXUS_CARD_RULES.md#bounded-ixian-betrayal-source-protocol--1-october-2026), [fresh local runtime and acceptance](NEXUS_CARD_RUNTIME.md#bounded-ixian-betrayal-local-runtime--1-october-2026), [five-facet internal topic](../game/reference.ts) | | Harkonnen Nexus Betrayal local preview | Fresh classic/base33/Nexus-only `harkonnen-betrayal` admits genuine personal/allied native matching calls. Native allied Karama/BG allowance comes first; a provisional uniform acknowledgement precedes the original outcome. Use returns the exact physical traitor immediately and preserves one automatic private actual Mentat replacement. The local ordering/privacy convention is not publisher/user priority and does not answer the previously asked universal reaction policy; public starts, old-game retrofit, arbitrary stacks and combined modules remain gated. Bounded native/private/recovery/CLI/phone verification is Partial development verified, not complete modes or deployment. | [Bounded source contract](NEXUS_HARKONNEN_RULES.md#bounded-betrayal-preview-contract--1-october-2026), [runtime limits](NEXUS_CARD_RUNTIME.md) | | CHOAM Secret Ally | Absent-faction Collection trade spends one actual Worthless card and the Nexus for two bank spice. It is separate from native Cunning and Auditor; the battle inspection alternative remains missing. | [Prototype contract](NEXUS_CHOAM_SECRET_ALLY.md) | diff --git a/game/engine.ts b/game/engine.ts index b8a7b80f..37aa48bc 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -9946,14 +9946,15 @@ function ixianNexusReplacementParent(g: Game): string { players:g.players.map(p => ({id:p.id,faction:p.faction,ally:p.ally,spice:p.spice,hand:p.hand})), deck:g.deck,discard:g.discard,aid:g.aid,nexus:g.nexusCards, ...(byFaction(g, 'richese') ? { richeseBidding:g.richeseBidding, - richeseCache:g.richeseCache,richeseRemoved:g.richeseRemoved } : {}), + richeseCache:g.richeseCache,richeseRemoved:g.richeseRemoved, + ...(g.richeseAuction ? { richeseAuction:g.richeseAuction } : {}) } : {}), decision:g.decision,response:g.response,phaseOpening:g.phaseOpening}); } function ixianNexusReplacementContext(g: Game) { - requireRule(g.auction, 'The Nexus purchased-card choice lost its normal auction.'); + requireRule(g.auction || g.richeseAuction, 'The Nexus purchased-card choice lost its original auction.'); return {status:g.status,phase:g.phase,turn:g.turn,sequence:g.nexusIxianReplacementCursor!.sequence, - expansions:g.expansions,richese:!!byFaction(g,'richese'), - auction:g.auction,sale:g.currentAuctionSale ?? null, + advanced:g.advanced,expansions:g.expansions,richese:!!byFaction(g,'richese'), + auction:g.auction,richeseAuction:g.richeseAuction ?? null,sale:g.currentAuctionSale ?? null, players:g.players.map(p => ({id:p.id,hand:p.hand,handLimit:handLimit(p)})), physicalCards:physicalTreacheryCards(g)}; } @@ -9980,7 +9981,8 @@ function ixianNexusReplacementIntegrity(g: Game): void { g.decision.event === pending.event && Object.keys(g.decision).every(key => ['kind','player','event'].includes(key)) && !g.response && !g.phaseOpening && !g.truthtrance && !g.pendingKarama && !g.pendingNullentropy && - !g.pendingTreacheryDiscard && !g.richeseAuction, + !g.pendingTreacheryDiscard && (g.currentAuctionSale?.origin === 'normal' + ? !g.richeseAuction : !g.auction && !!g.richeseAuction), 'The purchased-card choice lost its buyer or native decision priority.'); nexusRule(() => validateIxianReplacementSource(ixianNexusReplacementContext(g),pending, ixianNexusReplacementParent(g))); @@ -9989,11 +9991,11 @@ function offerIxianNexusReplacement(g: Game): boolean { if (!g.nexusIxianReplacementPreview) return false; const sale = g.currentAuctionSale; const buyer = sale && getPlayer(g,sale.winner); - if (!sale || !buyer || sale.origin !== 'normal' || - buyer.ally || !g.nexusCards?.cards?.hands[buyer.id]) return false; + if (!sale || !buyer || buyer.ally || !g.nexusCards?.cards?.hands[buyer.id]) return false; requireRule(!g.response && !g.phaseOpening && !g.truthtrance && !g.pendingKarama, 'Finish the original auction priority before offering the purchased-card choice.'); - const event = ixianReplacementEvent(g.turn,buyer.id,g.nexusIxianReplacementCursor!.sequence,g.auction!.index); + if (sale.origin !== 'normal') currentAuctionContinuationQuote(g, { kind: 'sale', free: sale.free }); + const event = ixianReplacementEvent(g.turn,buyer.id,g.nexusIxianReplacementCursor!.sequence,g.auction?.index ?? 0); g.decision = {kind:'nexusIxianReplacement',player:buyer.id,event}; g.active = buyer.id; g.pendingNexusIxianReplacement = nexusRule(() => createIxianReplacementSource( @@ -10004,7 +10006,7 @@ function ixianNexusReplacementView(g: Game,id: string) { const source = g.pendingNexusIxianReplacement; if (!source) return null; const own = source.buyer === id; - return {event:source.event,buyer:source.buyer,canPass:own, + return {event:source.event,buyer:source.buyer,origin:g.currentAuctionSale!.origin,canPass:own, canUse:own && g.nexusCards?.cards?.hands[id] === 'ixians', blocked:null as string | null,purchased:own ? structuredClone(source.card) : null}; } @@ -12348,7 +12350,7 @@ function settleRicheseSoldLot(g: Game, quote: Extract | null; sale: { winner: string; amount: number; @@ -128,7 +132,7 @@ function savedSource(value: unknown): value is IxianReplacementSource { text(value.parent) && text(value.signature); } function validateSavedSource(value: unknown): asserts value is IxianReplacementSource { - requireReplacement(savedSource(value), 'The replacement lost its canonical normal purchase receipt.'); + requireReplacement(savedSource(value), 'The replacement lost its canonical purchase receipt.'); requireReplacement( value.event === ixianReplacementEvent(value.turn, value.buyer, value.sequence, value.auctionIndex) && value.signature === sourceSignature(value), @@ -143,7 +147,48 @@ function validatePhysical(physical: readonly Card[], ctx?: IxianReplacementConte ); } -/** Authorize the original paid/Karama NORMAL sale, not an arbitrary held card. +/** Select only a delivered card backed by the current native sale authority. */ +function purchasedCard(ctx: IxianReplacementContext): Card { + const sale = ctx.sale; + requireReplacement(sale, 'Replacement needs its original auction sale.'); + if (sale.origin === 'normal') { + const auction = ctx.auction; + requireReplacement( + auction && ctx.richeseAuction === null && sale.seller === null && + auction.bidder === sale.winner && auction.bid === sale.amount && + whole(auction.index) && Array.isArray(auction.cards) && + selectedCard(auction.cards[auction.index], ctx), + 'Replacement must retain its exact original normal auction purchase.', + ); + return auction.cards[auction.index]; + } + const lot = ctx.richeseAuction; + requireReplacement( + ctx.auction === null && ctx.richese && ctx.expansions.includes('choam') && + lot && text(lot.event) && text(lot.cardId) && + (lot.source === 'cache' || (lot.source === 'blackMarket' && ctx.advanced)) && + lot.source === sale.origin && + text(lot.owner) && lot.owner === sale.seller && + Array.isArray(ctx.players) && ctx.players.some(player => plain(player) && player.id === lot.owner), + 'Replacement needs its exact native Richese lot and seated owner.', + ); + const outcome = lot.outcome; + requireReplacement( + outcome !== null && outcome.kind === 'sold' && + whole(outcome.amount) && outcome.amount > 0 && sale.free === false && + outcome.winner === sale.winner && outcome.amount === sale.amount, + 'The native Richese lot must have been sold to the buyer at the recorded price.', + ); + const buyer = ctx.players.find(player => plain(player) && player.id === sale.winner); + requireReplacement(buyer && Array.isArray(buyer.hand), + 'Replacement needs the native lot in its original buyer hand.'); + const card = buyer.hand.find((card: Card) => card?.id === lot.cardId); + requireReplacement(selectedCard(card, ctx), + 'Replacement needs the exact canonical delivered native Richese card.'); + return card; +} + +/** Authorize the original normal or native Richese purchase, not an arbitrary held card. * The engine supplies its current source-specific parent and live custody census. * Neither payment nor draw allocation is reconstructed here. */ export function validateIxianReplacementSource( @@ -156,14 +201,11 @@ export function validateIxianReplacementSource( text(parent) && source.parent === parent, 'The purchased card choice has lost its current parent or cursor.', ); - const auction = ctx.auction, sale = ctx.sale; + const card = purchasedCard(ctx), sale = ctx.sale!; requireReplacement( - auction && sale && sale.origin === 'normal' && sale.seller === null && - sale.winner === source.buyer && sale.amount === source.amount && sale.free === source.free && - auction.bidder === source.buyer && auction.bid === source.amount && - whole(auction.index) && auction.index === source.auctionIndex && Array.isArray(auction.cards) && - selectedCard(auction.cards[auction.index], ctx) && sameCard(auction.cards[auction.index], source.card), - 'Replacement must retain its exact original normal auction purchase.', + sale.winner === source.buyer && sale.amount === source.amount && sale.free === source.free && + source.auctionIndex === (ctx.auction?.index ?? 0) && sameCard(card, source.card), + 'Replacement must retain its exact original auction purchase.', ); validatePhysical(ctx.physicalCards, ctx); requireReplacement( @@ -191,16 +233,11 @@ export function validateIxianReplacementSource( export function createIxianReplacementSource( ctx: IxianReplacementContext, event: string, parent: string, ): IxianReplacementSource { - const auction = ctx.auction, sale = ctx.sale; - requireReplacement( - auction && sale && Array.isArray(auction.cards) && whole(auction.index) && - canonicalCard(auction.cards[auction.index]), - 'Replacement needs an actual canonical purchased auction card.', - ); + const card = purchasedCard(ctx), sale = ctx.sale!; const source: IxianReplacementSource = { version: 1, event, buyer: sale.winner, turn: ctx.turn, sequence: ctx.sequence, - auctionIndex: auction.index, amount: sale.amount, free: sale.free, - card: { ...auction.cards[auction.index] }, parent, signature: '', + auctionIndex: ctx.auction?.index ?? 0, amount: sale.amount, free: sale.free, + card: { ...card }, parent, signature: '', }; source.signature = sourceSignature(source); validateIxianReplacementSource(ctx, source, parent); diff --git a/game/reference.ts b/game/reference.ts index c04dcbc9..dafef3b2 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -4510,13 +4510,14 @@ export const RULE_TOPICS: RuleTopic[] = [ category: 'Advanced & expansions', coverage: 'Partial', developmentStage: 'Verified', - summary: 'In the fresh local ixian-replacement preview, an unallied holder with Ixians absent may spend the physical Ixian Nexus to discard exactly the card just bought in a normal auction and draw the next Treachery deck card privately.', - searchText: 'Ixian Nexus Secret Ally purchased card replacement normal auction Karama full hand empty deck same card', + summary: 'An unallied holder with Ixians absent may spend Ixian Nexus to replace the exact card just bought in a normal auction or supported native Richese sale, preserving original settlement and drawing the actual deck top privately.', + searchText: 'Ixian Nexus Secret Ally purchased card replacement normal auction cache Black Market Karama full hand empty deck same card', steps: [ - 'This fresh local profile admits two-to-six unique seats in Basic/Advanced: classics with base33, native Tleilaxu plus classics with Ix47, or native Richese plus classics with CHOAM35 and its separate ten-card cache, always with Ixians absent and Nexus alone. Only normal-auction purchases qualify; original cache setup/auctions remain distinct from a cache-origin replacement. Optional Sandtrout is omitted only for fresh native Ix setup. Other natives, Leader Skills, Homeworlds, Semuta, Discoveries, Ecaz variants and overlays remain guarded. No public start or played-save upgrade.', + 'The fresh local profile admits two-to-six unique seats in Basic/Advanced: classics/base33, Tleilaxu plus classics/Ix47, or Richese plus classics/CHOAM35 and original cache, with Ixians absent and Nexus alone. Normal purchases qualify, as do genuine positive Richese cache sales in Basic/Advanced and Black Market sales in Advanced. Sandtrout is omitted only for fresh native Ix setup. Other natives/modules and public starts or played-save conversion remain excluded.', 'Nexus has no starting deal. A draw requires a qualifying closing Nexus with an alliance. A two-seat profile can continue its base game but cannot naturally create an unallied Secret Ally holder; actual replacement needs at least three seats, or four for two simultaneous unallied recipients.', 'The photographed publisher face grants an independent Secret Ally operation when Ixians are absent. Remain unallied and hold the actual singleton Ixian Nexus. Native Ixian allied replacement is a different cancelable advantage and is not removed or used as a fallback. The adopted unofficial Advanced rulebook supplies relevant core rules, not this absent Nexus face or a new Nexus ruling.', - 'Wait for a genuine normal-auction purchase, paid with spice or through Karama, including direct acquisition before a positive bid. A successful native Advanced BG Worthless-as-Karama conversion gets the same independent replacement choice after its native counter resolves. Canceling direct acquisition supplies no purchase; a canceled winning-payment conversion follows native funded spice recovery and, if bought, supplies a paid receipt instead. Cache, Black Market and special-purchase origins retain their separate guarded Nexus scope. Gifts, bonus draws, Technology swaps and old hand cards are not purchases.', + 'A normal purchase may be paid with spice or Karama, including successful Advanced BG conversion after its native counter. A denied direct conversion supplies no purchase; funded winning-payment recovery instead supplies a paid receipt. Actual Richese native sales are already paid and delivered before the same owned choice; original recipient/income and native lot continuation follow once. Unbid owner keeps/retains, gifts, bonus draws, Technology swaps and old hand cards supply no purchase. Direct Richese special-Karama sources remain separate.', + 'Provisional cache scope: the independent absent-Ixian Nexus grant applies to a genuine cache purchase under the selected literal reading. It is not the native Ixian alliance advantage: that advantage’s printed cache exclusion is unchanged. The table explains this policy before Use; it is product interpretation, not a newly located publisher FAQ or an explicit user-selected rule.', 'Every publicly unallied supported buyer with public held-Nexus presence receives the same neutral Purchased card choice, irrespective of the secret face. An irrelevant-face buyer may Pass but cannot Use. This is an inferred development privacy convention, not publisher-prescribed timing or a general resolution of pending anytime and response-priority questions.', 'Only the buyer may inspect its already-owned purchased card in this choice. You may inspect your own actually held Nexus through the existing authorized inspector; the prompt does not identify anybody else’s Nexus. Rivals receive no purchased or replacement face, private price, receipt, deck order or eligibility reason. Earlier Atreides auction inspection does not grant a peek of the replacement.', 'Pass keeps your purchased card and Nexus and closes this purchase opportunity. Use spends your physical Ixian Nexus once and discards exactly that just-purchased Treachery Card before drawing the actual next deck card. You cannot select another held card, replacement face, price or recipient; both actions bind only the offered event.', @@ -4529,11 +4530,11 @@ export const RULE_TOPICS: RuleTopic[] = [ example: 'You buy a card for four spice and inspect it privately. Pass keeps it and the Nexus. Use keeps the original four-spice purchase settled, spends Ixian Nexus, discards that exact card and draws the next deck card. If the draw deck is empty, its new discard joins the actual recycled pile and can be drawn again.', related: ['nexus-cards', 'bidding', 'ix-technology', 'card-karama', 'alliance-funding', 'privacy', 'implementation-checklist'], checklist: [ - { area: 'Implementation', status: 'Partial', detail: 'Normal purchase, physical replacement and closed history bind original settlement. Native Richese adds selected CHOAM35/cache custody without enabling special-origin sales; unselected card families still reject. BG conversion/paid recovery and Harkonnen replacement-before-bonus remain source-specific. Other modules remain guarded.', evidence: ['game/nexus-ixian-replacement.ts', 'game/engine.ts', 'tools/prototype-room.ts'] }, - { area: 'Player controls', status: 'Partial', detail: 'Neutral Purchased card choice separates buyer-only inspection and Use/Keep from waiting. Original390px Richese-family normal purchases exercise Basic Atreides Use and Advanced Richese Keep, preserving original income/cache and next lot. Two tool-only stale-node races were recovered without replay; no application error or overflow. Harkonnen retains its visible provisional ordering. Broader acceptance remains open.', evidence: ['components/nexus-ixian-replacement.tsx', 'components/game-table.tsx'] }, - { area: 'AI', status: 'Partial', detail: 'Four minimal legal policies use only their own projected event while native settlement waits. Sixteen original-state actions cover native Richese normal purchases across both modes and buyer factions; earlier BG/Harkonnen/Tleilaxu paths remain source-qualified in the contract. Full strategy/calibration remain unfinished.', evidence: ['game/bots.ts'] }, - { area: 'Documentation', status: 'Partial', detail: 'Printed replacement, original settlement, selected catalogue/cache membership, full-hand/same-card semantics and provisional Harkonnen order are documented. Native Richese normal purchases are distinct from still-gated cache/Black Market/special origins, competing effects and full-family acceptance; no publisher priority is invented.', evidence: ['docs/NEXUS_IXIAN_REPLACEMENT_RULES.md', 'docs/NEXUS_CARD_RULES.md', 'docs/RULE_DECISIONS.md'] }, - { area: 'Verification', status: 'Partial', detail: 'Earlier source-qualified evidence remains in the contract. Native Richese adds six selected-catalogue and twelve original behavior cases in affected65/65, eight original runtime paths, sixteen legal policy actions and two390px normal-purchase journeys. Original final-lot/cache continuation and the still-gated real cache purchase are exercised. Source repairs follow actual automatic response advancement; typing annotations do not alter behavior. No new build, saved/deployed or broad assurance; all families/modules remain Partial.', evidence: ['tests/nexus-ixian-replacement.test.ts', 'tests/nexus-ixian-replacement-engine.test.ts', 'tests/fixtures/nexus-ixian-native-depleted.json', 'tests/fixture-nexus-ixian-replacement.ts', 'tests/fixture-native-ixian-replacement.ts', 'tests/native-ixian-replacement.test.ts', 'tests/bg-ixian-replacement.test.ts', 'tests/fixture-bg-ixian-replacement.ts', 'tests/harkonnen-ixian-replacement.test.ts', 'tests/fixture-harkonnen-ixian-replacement.ts', 'tests/richese-ixian-replacement.test.ts', 'tests/fixture-richese-ixian-replacement.ts'] }, + { area: 'Implementation', status: 'Partial', detail: 'Original normal and native Richese sold-lot receipts bind delivered card, price and native continuation. Cache works in both modes under a labelled provisional independent grant; Black Market stays Advanced-only. Unbid retention is not a purchase. Single native lots use index0 without a fabricated normal row; v1 receipt shape and normal parents remain unchanged. Direct special sources and other modules stay separate.', evidence: ['game/nexus-ixian-replacement.ts', 'game/engine.ts', 'tools/prototype-room.ts'] }, + { area: 'Player controls', status: 'Partial', detail: 'Buyer-only purchased-card inspection and Use/Keep reuse the neutral protocol. Actual390px cache Use shows the provisional scope, retires to Revival and preserves one seller credit; Advanced Black Market Keep resumes original Richese first/last controls. Origin is public category only. Harkonnen keeps its separate visible provisional order. No overflow or browser/application error; broader acceptance remains open.', evidence: ['components/nexus-ixian-replacement.tsx', 'components/game-table.tsx'] }, + { area: 'AI', status: 'Partial', detail: 'Four minimal legal policies use their own projected offer while original settlement waits. Twelve original-state actions cover cache Basic/Advanced and Advanced Black Market; earlier source-qualified paths remain in the contract. No full strategy or calibration is claimed.', evidence: ['game/bots.ts'] }, + { area: 'Documentation', status: 'Partial', detail: 'Real sold-lot authority, v1 source preservation, original recipient/continuation, selected catalogue, same-card/full-hand semantics and explicit provisional cache/Harkonnen interpretations are documented. The native ally cache exclusion is unchanged; direct special sources, other modules and publisher priority remain separate.', evidence: ['docs/NEXUS_IXIAN_REPLACEMENT_RULES.md', 'docs/NEXUS_CARD_RULES.md', 'docs/RULE_DECISIONS.md'] }, + { area: 'Verification', status: 'Partial', detail: 'Affected nine-file98/98, types/lint/build, six original runtime paths, twelve legal policy actions and two390px native-origin journeys pass. Eleven actual native scenario cases cover already-paid timing, Use/Keep and unbid retention; source tests reject missing/mismatched/competing/open/unbid lots and Basic Black Market. The fixture follows actual response priority. An obsolete DTO shape pin was deleted without losing semantic rival privacy assertions. No new broad, HTTP, saved/deployed assurance; all five facets remain Partial.', evidence: ['tests/nexus-ixian-replacement.test.ts', 'tests/nexus-ixian-replacement-engine.test.ts', 'tests/fixtures/nexus-ixian-native-depleted.json', 'tests/fixture-nexus-ixian-replacement.ts', 'tests/fixture-native-ixian-replacement.ts', 'tests/native-ixian-replacement.test.ts', 'tests/bg-ixian-replacement.test.ts', 'tests/fixture-bg-ixian-replacement.ts', 'tests/harkonnen-ixian-replacement.test.ts', 'tests/fixture-harkonnen-ixian-replacement.ts', 'tests/richese-ixian-replacement.test.ts', 'tests/fixture-richese-ixian-replacement.ts', 'tests/richese-ixian-special-replacement.test.ts', 'tests/fixture-richese-ixian-special-replacement.ts'] }, ], }, { diff --git a/tests/fixture-richese-ixian-replacement.ts b/tests/fixture-richese-ixian-replacement.ts index c74c564b..b55be4fd 100644 --- a/tests/fixture-richese-ixian-replacement.ts +++ b/tests/fixture-richese-ixian-replacement.ts @@ -79,7 +79,7 @@ export function nextRicheseIxianReplacementNativeStep( keep: owner.hand.length < handLimit(owner) } }; } const lot = game.richeseAuction; - if (!decision && lot && !lot.outcome) { + if (!decision && !game.response && lot && !lot.outcome) { const actor = lot.method === 'silent' ? lot.order.find(id => lot.eligible.includes(id) && !Object.hasOwn(lot.sealed, id)) : lot.active; diff --git a/tests/fixture-richese-ixian-special-replacement.ts b/tests/fixture-richese-ixian-special-replacement.ts new file mode 100644 index 00000000..3fb06498 --- /dev/null +++ b/tests/fixture-richese-ixian-special-replacement.ts @@ -0,0 +1,394 @@ +import assert from 'node:assert/strict'; +import { applyAction, handLimit, type Action, type Game } from '../game/engine'; +import type { Card } from '../game/cards'; +import type { RicheseAuction } from '../game/richese-auction'; +import { settleIxianReplacementFixture } from './fixture-nexus-ixian-replacement'; +import { + createRicheseIxianReplacementFixture, nextRicheseIxianReplacementNativeStep, + type RicheseIxianReplacementStep, +} from './fixture-richese-ixian-replacement'; + +/** The two native Richese sources a genuine purchased-card replacement may bind: + * the compulsory cache lot and the owner's own Black Market lot. */ +export type RicheseIxianSpecialReplacementOrigin = 'cache' | 'blackMarket'; +/** A real positive Once Around sale or the genuine zero-bid outcome. */ +export type RicheseIxianSpecialReplacementMode = 'sold' | 'unbid'; +/** The engine's own native lot facts, as bound by the live game. */ +export type RicheseIxianSpecialReplacementLot = + Pick; + +export type RicheseIxianSpecialReplacementOptions = { + /** Continue only a fresh authenticated undealt Richese/CHOAM lobby or its admitted setup. */ + initial?: Game; + /** Native profile mode. The Black Market offer exists only in Advanced. */ + advanced?: boolean; + /** Native source of the genuine purchase. Defaults to the compulsory cache lot. */ + origin?: RicheseIxianSpecialReplacementOrigin; + /** Real positive Once Around sale (default) or the genuine zero-bid outcome. */ + mode?: RicheseIxianSpecialReplacementMode; + /** Shared CHOAM addition bought as the ordinary normal lot before the source lot. */ + purchasedId?: string; + /** Original shared CHOAM addition left as the actual top card after that normal purchase. */ + replacementId?: string; + /** Physical cache card the genuine turn-one cache lot keeps; supplies a Black Market offer. */ + cacheCardId?: string; + /** Cache origin: physical cache card the genuine compulsory cache lot offers. */ + offerCardId?: string; + /** Positive winning bid for the genuine Once Around sale. Default 1. */ + bid?: number; +}; + +type RicheseIxianSpecialReplacementCommon = { + /** Admitted fresh setup before its first turn; only undealt decks were ordered. */ + setup: Game; + /** Genuine unallied Ixian Nexus holder about to close the Nexus; only undealt decks ordered. */ + beforeNexusDraw: Game; + nexusDrawAction: Action; + /** The exact physical card this fixture offered in the real native lot. */ + offered: Card; + /** The real native lot's own event; never a fabricated auction row. */ + lotEvent: string; + lotMethod: 'onceAround'; + /** Live native lot at the returned state, or null once the round retired it. */ + richeseAuction: RicheseIxianSpecialReplacementLot | null; + buyer: string; + /** The Richese round owner whose own card or cache card was offered. */ + seller: string; + emperor: string | null; + origin: RicheseIxianSpecialReplacementOrigin; + advanced: boolean; + /** The original physical cache at this point; a purchase may only shrink it by its own lot. */ + cacheBefore: Card[]; + /** Cards a genuine turn-one cache lot kept instead of buying. */ + keptCacheCards: string[]; + /** Black Market origin: the genuine pre-decline offer decision this fixture replaced. */ + blackMarketOffer: { actor: string; event: string; card: Card } | null; + /** Where the original native suffix resumes after the buyer's choice. */ + suffix: 'phase' | 'declaration'; +}; + +export type RicheseIxianSpecialReplacementSaleFixture = RicheseIxianSpecialReplacementCommon & { + mode: 'sold'; + /** State with the pending purchased-card offer, after payment and delivery only. */ + game: Game; + /** Public purchased-card event opened for this genuinely paid native sale. */ + event: string; + /** The exact delivered card the offer binds; never a top deck or unsold lot card. */ + purchased: Card; + /** Actual top of the Treachery Deck at the offer: the only legal replacement draw. */ + nextCard: Card; + bid: number; + /** Real native state immediately before the action that paid and delivered the lot. */ + beforePurchase: Game; + /** The real native action that closed the lot, and by whom it was taken. */ + purchaseActor: string; + purchaseAction: Action; + /** Buyer hand before paying for the source lot; the delivered card is excluded. */ + buyerHandBefore: Card[]; + beforeSpice: number; + /** Buyer spice at the offer: the native payment is already debited exactly once. */ + paidSpice: number; + /** Seller spice at the offer: the native credit has not happened yet. */ + sellerSpiceAtOffer: number; + /** Emperor spice at the offer; an unallied native sale owes no Emperor gift. */ + emperorSpiceAtOffer: number | null; +}; + +export type RicheseIxianSpecialReplacementUnbidFixture = RicheseIxianSpecialReplacementCommon & { + mode: 'unbid'; + /** State where the genuine zero-bid outcome retired the lot; no sale was ever paid. */ + game: Game; + /** Real native state immediately before the action that retired the lot. */ + beforeFinalAction: Game; + finalActor: string; + finalAction: Action; + /** Cache origin: the keeper's original unbid choice, applied only when room existed. */ + kept: boolean | null; + /** The physical card no purchase ever bound. */ + card: Card; +}; + +export type RicheseIxianSpecialReplacementFixture = + | RicheseIxianSpecialReplacementSaleFixture + | RicheseIxianSpecialReplacementUnbidFixture; + +function player(game: Game, id: string) { + const seat = game.players.find(candidate => candidate.id === id); + assert.ok(seat, `The original seat ${id} must remain at the table.`); + return seat; +} + +function liveLot(game: Game): RicheseIxianSpecialReplacementLot | null { + const lot = game.richeseAuction; + return lot ? { + event: lot.event, cardId: lot.cardId, source: lot.source, + owner: lot.owner, outcome: structuredClone(lot.outcome), + } : null; +} + +/** Exported native seam: drive the original undealt Richese/CHOAM turn with the + * genuine native policy, so a throwaway API/browser smoke can reach the source. */ +export function nextRicheseIxianSpecialReplacementNativeStep( + game: Game, + cacheCardId?: string, +): RicheseIxianReplacementStep { + return nextRicheseIxianReplacementNativeStep(game, cacheCardId); +} + +function nativeStep(game: Game, cacheCardId?: string): Game { + const next = nextRicheseIxianSpecialReplacementNativeStep(game, cacheCardId); + return applyAction(game, next.actor, next.action); +} + +/** Drive only the original native suffix after the buyer's own choice: the cache + * lot ends its round into the phase boundary, the Black Market sale resumes the + * original declaration. Never bids, pays, draws or invents a new lot. */ +export function settleRicheseIxianSpecialReplacementSuffix( + state: Game, + origin: RicheseIxianSpecialReplacementOrigin, + cacheCardId?: string, +): Game { + let game = state; + for (let count = 0; count < 50; count++) { + if (origin === 'cache' + ? game.richeseBidding?.stage === 'complete' + : game.decision?.kind === 'richeseDeclaration') return game; + game = nativeStep(game, cacheCardId); + } + throw new Error('The original native Richese suffix did not settle.'); +} + +/** A genuine special-origin purchase for the Ixian Nexus purchased-card choice. + * + * Provenance, all through the original private initializer and original actions: + * the profile is the fresh native Richese + Atreides + Emperor/CHOAM prototype + * with the real closing Nexus. The unallied Atreides holder buys a real normal + * CHOAM lot at the last scheduled lot and keeps it with its Ixian Nexus card + * (cache origin), or the fixture replays the genuine Nexus turn from its own + * undealt snapshot (Black Market origin). + * + * Cache origin then lets the compulsory cache lot run as a real Once Around for + * a card the ten-card cache still holds. Black Market origin stops at Richese's + * own pre-decline window and offers the one card a genuine turn-one cache lot + * actually kept in the owner's hand. The buyer's positive bid is a real funded + * native bid. All bid, wallet, clock and card changes come from native actions; + * no played state is assigned. + * + * The returned `game` already contains the paid sale (buyer debited, card + * delivered) and the pending purchased-card offer; the original seller credit, + * lot retirement and native suffix still belong to the original continuation, + * so Use/Keep can never re-debit or re-credit the purchase. */ +export function createRicheseIxianSpecialReplacementFixture( + options: RicheseIxianSpecialReplacementOptions = {}, +): RicheseIxianSpecialReplacementFixture { + const origin = options.origin ?? 'cache'; + const mode = options.mode ?? 'sold'; + const advanced = options.advanced ?? origin === 'blackMarket'; + if (origin === 'blackMarket') + assert.ok(advanced, + 'The native Black Market offer exists only in the Advanced profile; pass advanced: true or leave it unset.'); + const bid = options.bid ?? 1; + assert.ok(Number.isSafeInteger(bid) && bid > 0, + 'A genuine Once Around purchase needs a positive integer bid.'); + const cacheCardId = options.cacheCardId; + const base = createRicheseIxianReplacementFixture({ + initial: options.initial, advanced, endingAuction: true, + purchasedId: options.purchasedId, replacementId: options.replacementId, cacheCardId, + }); + assert.equal(base.advanced, advanced); + const seller = base.setup.players.find(candidate => candidate.faction === 'richese')!.id; + const buyer = base.buyer; + assert.notEqual(buyer, seller, + 'The genuine source purchase needs a buyer other than its own Richese seller.'); + const emperor = base.setup.players.find(candidate => candidate.faction === 'emperor')?.id ?? null; + assert.equal(player(base.game, buyer).ally, null, + 'The source purchase needs the unallied Ixian Nexus holder as its buyer.'); + assert.equal(base.game.nexusCards!.cards!.hands[buyer], 'ixians', + 'The source purchase needs its buyer still holding the genuine Ixian Nexus card.'); + assert.ok(!base.setup.players.some(candidate => candidate.faction === 'ixians'), + 'The native Richese/CHOAM profile must have native Ixians absent.'); + const keptCacheCards = [...base.keptCacheCards]; + const cacheBefore = structuredClone(base.cacheAtPurchase); + let game: Game; + let offered: Card; + let blackMarketOffer: RicheseIxianSpecialReplacementCommon['blackMarketOffer'] = null; + + if (origin === 'cache') { + // Keep the real normal purchase and the unallied Ixian Nexus card, then let + // Richese's own compulsory cache offer stand on the original physical cache. + game = settleIxianReplacementFixture(applyAction(structuredClone(base.game), buyer, + { type: 'nexusIxianReplacementPass', event: base.event })); + assert.equal(game.pendingNexusIxianReplacement, null, + 'The genuine normal purchase must close before the compulsory cache lot.'); + assert.deepEqual(game.decision, { kind: 'richeseCache', player: seller }); + assert.equal(game.richeseBidding!.stage, 'cacheOffer'); + const cache = game.richeseCache; + assert.ok(cache, 'The cache lot needs the original physical cache.'); + assert.deepEqual(cache, cacheBefore, + 'The genuine normal purchase must not touch the compulsory cache.'); + const chosen = options.offerCardId ?? cache.find(card => !keptCacheCards.includes(card.id))!.id; + const cached = cache.find(card => card.id === chosen); + assert.ok(cached, 'The offered cache card must still be in the physical cache.'); + assert.ok(!keptCacheCards.includes(cached.id), + 'The cache lot must not re-offer a card an earlier genuine lot already kept.'); + offered = structuredClone(cached); + game = applyAction(game, seller, { type: 'decision', event: game.richeseBidding!.event, + card: offered.id, method: 'onceAround', direction: 'clockwise' }); + } else { + game = structuredClone(base.beforeNexusDraw); + game = applyAction(game, buyer, base.nexusDrawAction); + assert.equal(game.nexusCards!.cards!.hands[buyer], 'ixians'); + for (const id of game.nexusCards!.phase!.eligible) + if (!game.nexusCards!.phase!.done.includes(id)) + game = applyAction(game, id, { type: 'nexusCardChoice', turn: game.turn, + card: null, choice: 'keep', ownRedraws: 0 }); + for (let count = 0; game.decision?.kind !== 'richeseBlackMarket' && count < 600; count++) + game = nativeStep(game, cacheCardId); + const decision = game.decision; + assert.equal(decision?.kind, 'richeseBlackMarket', + 'The genuine Advanced round must still offer its own Black Market window.'); + assert.equal(game.richeseBidding!.stage, 'blackMarketOffer'); + assert.equal(game.richeseAuction, null); + const held = player(game, seller).hand.filter(card => keptCacheCards.includes(card.id)); + assert.equal(held.length, 1, + 'The genuine turn-one cache Keep must supply exactly one held Richese card.'); + const candidate = options.offerCardId + ? held.find(card => card.id === options.offerCardId) + : held[0]; + assert.ok(candidate, 'The Black Market offer needs a genuinely held Richese card.'); + offered = structuredClone(candidate); + blackMarketOffer = { actor: decision.player, event: game.richeseBidding!.event, + card: structuredClone(offered) }; + assert.equal(game.richeseOfferedCard, null); + game = applyAction(game, seller, { type: 'decision', event: game.richeseBidding!.event, + card: offered.id, method: 'onceAround', direction: 'clockwise' }); + assert.equal(game.richeseAuction?.source, 'blackMarket'); + assert.equal(game.richeseAuction!.cardId, offered.id); + assert.equal(game.richeseOfferedCard?.id, offered.id); + } + + const open = game.richeseAuction; + assert.ok(open, 'The genuine native Once Around lot must be open.'); + assert.equal(open.source, origin); + assert.equal(open.method, 'onceAround'); + assert.equal(open.cardId, offered.id); + assert.equal(open.owner, seller); + assert.equal(open.outcome, null); + const lotEvent = open.event; + let beforeFinalAction = structuredClone(game); + let finalActor = ''; + let finalAction: Action = { type: 'richeseBid', event: lotEvent, amount: null }; + for (let count = 0; count < 200; count++) { + if (mode === 'sold' && game.pendingNexusIxianReplacement) break; + if (game.response || game.phaseOpening || game.pendingTreacheryDiscard) { + const next = nextRicheseIxianSpecialReplacementNativeStep(game, cacheCardId); + beforeFinalAction = structuredClone(game); + finalActor = next.actor; + finalAction = next.action; + game = applyAction(game, next.actor, next.action); + continue; + } + const lot = game.richeseAuction; + if (!lot || lot.outcome) break; + assert.equal(lot.event, lotEvent, 'The genuine lot must keep its own event.'); + const actor = lot.active; + assert.ok(actor, 'A genuine Once Around lot always names its next bidder.'); + const action: Action = mode === 'sold' && actor === buyer && lot.bid === 0 + ? { type: 'richeseBid', event: lotEvent, amount: bid } + : { type: 'richeseBid', event: lotEvent, amount: null }; + beforeFinalAction = structuredClone(game); + finalActor = actor; + finalAction = action; + game = applyAction(game, actor, action); + } + + const common = { + setup: base.setup, beforeNexusDraw: base.beforeNexusDraw, + nexusDrawAction: base.nexusDrawAction, offered, lotEvent, lotMethod: 'onceAround' as const, + richeseAuction: liveLot(game), buyer, seller, emperor, origin, advanced, + cacheBefore, keptCacheCards, blackMarketOffer, + suffix: (origin === 'cache' ? 'phase' : 'declaration') as 'phase' | 'declaration', + }; + + if (mode === 'sold') { + const closed = game.richeseAuction; + assert.ok(closed, 'The paid native lot must remain the offer’s own source.'); + assert.deepEqual(closed.outcome, { kind: 'sold', winner: buyer, amount: bid }); + const source = game.pendingNexusIxianReplacement; + assert.ok(source, 'The genuinely paid native sale must open the purchased-card offer.'); + assert.equal(source.version, 1); + assert.equal(source.buyer, buyer); + assert.equal(source.turn, game.turn); + assert.equal(source.auctionIndex, 0, + 'A native Richese lot binds lot index 0, never a fabricated Game or normal-auction row.'); + assert.equal(source.amount, bid); + assert.equal(source.free, false); + assert.deepEqual(source.card, offered); + assert.deepEqual(game.decision, { kind: 'nexusIxianReplacement', player: buyer, + event: source.event }); + assert.equal(game.auction, null); + assert.equal(game.currentAuctionSale?.origin, origin); + assert.equal(game.players.flatMap(seat => seat.hand) + .filter(card => card.id === offered.id).length, 1, + 'The paid native card must be delivered exactly once.'); + if (origin === 'cache') { + assert.equal(game.richeseCache!.some(card => card.id === offered.id), false); + assert.deepEqual(game.richeseCache, + cacheBefore.filter(card => card.id !== offered.id)); + } else { + assert.deepEqual(game.richeseCache, cacheBefore, + 'A Black Market sale must never touch the compulsory cache.'); + assert.equal(player(game, seller).hand.some(card => card.id === offered.id), false, + 'The concealed Black Market card must leave the seller’s own hand.'); + assert.equal(player(beforeFinalAction, seller).hand.some(card => card.id === offered.id), + true, 'The Black Market offer must come from a genuinely held card.'); + } + assert.ok(game.deck.length > 0); + const nextCard = structuredClone(game.deck[0]); + assert.notEqual(nextCard.id, offered.id); + return { + ...common, mode: 'sold', game, event: source.event, + purchased: structuredClone(offered), nextCard, bid, + beforePurchase: beforeFinalAction, purchaseActor: finalActor, purchaseAction: finalAction, + buyerHandBefore: structuredClone(player(beforeFinalAction, buyer).hand), + beforeSpice: player(beforeFinalAction, buyer).spice, + paidSpice: player(game, buyer).spice, + sellerSpiceAtOffer: player(game, seller).spice, + emperorSpiceAtOffer: emperor ? player(game, emperor).spice : null, + }; + } + + assert.equal(finalAction.type, 'richeseBid'); + assert.equal(finalAction.amount, null, + 'A genuine unbid outcome can only come from real zero offers.'); + assert.equal(game.pendingNexusIxianReplacement, null, + 'A lot that was never sold is not a purchase and opens no purchased-card offer.'); + assert.equal(game.currentAuctionSale, null); + if (origin === 'cache') { + const lot = game.richeseAuction; + assert.ok(lot, 'The unbid cache lot waits for its owner’s own choice.'); + assert.deepEqual(lot.outcome, { kind: 'unbid' }); + assert.deepEqual(game.decision, { kind: 'richeseUnbid', player: seller }); + const sellerSeat = player(game, seller); + const kept = sellerSeat.hand.length < handLimit(sellerSeat); + game = applyAction(game, seller, { type: 'decision', event: game.richeseBidding!.event, + keep: kept }); + assert.equal(game.pendingNexusIxianReplacement, null); + assert.equal(player(game, seller).hand.some(card => card.id === offered.id), kept); + assert.equal((game.richeseRemoved ?? []).some(card => card.id === offered.id), !kept); + return { + ...common, richeseAuction: liveLot(game), mode: 'unbid', game, + beforeFinalAction, finalActor, finalAction, kept, card: structuredClone(offered), + }; + } + assert.equal(game.richeseAuction, null, 'The retained Black Market lot must retire itself.'); + assert.deepEqual(game.decision, { kind: 'richeseDeclaration', player: seller }); + assert.equal(game.richeseBidding!.blackMarketSold, false); + assert.equal(player(game, seller).hand.some(card => card.id === offered.id), true, + 'The unbid Black Market card must stay with its own seller.'); + assert.equal(game.richeseOfferedCard, null); + return { + ...common, richeseAuction: null, mode: 'unbid', game, + beforeFinalAction, finalActor, finalAction, kept: null, card: structuredClone(offered), + }; +} diff --git a/tests/native-ixian-replacement.test.ts b/tests/native-ixian-replacement.test.ts index c6fc7119..70a93bb0 100644 --- a/tests/native-ixian-replacement.test.ts +++ b/tests/native-ixian-replacement.test.ts @@ -94,6 +94,7 @@ void test('native descriptor admission rejects forged Ix effects and unrelated R const ctx: IxianReplacementContext = { status: f.game.status, phase: f.game.phase, turn: f.game.turn, sequence: source.sequence, expansions: f.game.expansions, richese: false, + advanced: f.game.advanced, richeseAuction: null, auction: { ...f.game.auction!, cards: f.game.auction!.cards.map((card, index) => index === f.auctionIndex ? corrupt : card) }, sale: f.game.currentAuctionSale!, diff --git a/tests/nexus-ixian-replacement-engine.test.ts b/tests/nexus-ixian-replacement-engine.test.ts index d97d0358..8798febc 100644 --- a/tests/nexus-ixian-replacement-engine.test.ts +++ b/tests/nexus-ixian-replacement-engine.test.ts @@ -173,13 +173,12 @@ void test('public neutral offer is independent of secret held face and rivals se const irrelevant = createIxianNexusReplacementFixture({face:'richese'}); const rival = ix.game.players.find(p => p.id !== ix.buyer)!.id; assert.deepEqual(viewGame(ix.game,rival).nexusIxianReplacement,viewGame(irrelevant.game,rival).nexusIxianReplacement); - assert.deepEqual(viewGame(ix.game,rival).nexusIxianReplacement, - {event:ix.event,buyer:ix.buyer,canPass:false,canUse:false,blocked:null,purchased:null}); + assert.equal(viewGame(ix.game,rival).nexusIxianReplacement!.purchased,null); + assert.equal(viewGame(ix.game,rival).nexusIxianReplacement!.canUse,false); assert.equal(viewGame(ix.game,ix.buyer).nexusIxianReplacement!.canUse,true); const otherOffer = viewGame(irrelevant.game,irrelevant.buyer).nexusIxianReplacement!; assert.equal(otherOffer.canPass,true); assert.equal(otherOffer.canUse,false); - assert.equal(otherOffer.blocked,null); reject(irrelevant.game,irrelevant.buyer,{type:'nexusIxianReplacementUse',event:irrelevant.event}); const pass = applyAction(irrelevant.game,irrelevant.buyer,{type:'nexusIxianReplacementPass',event:irrelevant.event}); assert.equal(pass.nexusCards!.cards!.hands[irrelevant.buyer],'richese'); diff --git a/tests/nexus-ixian-replacement.test.ts b/tests/nexus-ixian-replacement.test.ts index 20a9c10c..f7d14302 100644 --- a/tests/nexus-ixian-replacement.test.ts +++ b/tests/nexus-ixian-replacement.test.ts @@ -20,8 +20,9 @@ const parent = 'original-native-paid-sale-parent'; function context(): IxianReplacementContext { return { status: 'playing', phase: 3, turn: 1, sequence: 0, - expansions: [], richese: false, + expansions: [], richese: false, advanced: false, auction: { cards: [{ ...purchased }, { ...unsold }], index: 0, bidder: 'buyer', bid: 4 }, + richeseAuction: null, sale: { winner: 'buyer', amount: 4, free: false, origin: 'normal', seller: null }, players: [ { id: 'buyer', hand: [{ ...older }, { ...purchased }], handLimit: 4 }, @@ -47,6 +48,117 @@ function source(ctx: IxianReplacementContext = context()) { ); } +function nativeContext(origin: 'cache' | 'blackMarket'): IxianReplacementContext { + const card = origin === 'cache' ? richeseCards()[0] : purchased; + const ctx = context(); + ctx.expansions = ['choam']; + ctx.richese = true; + ctx.advanced = origin === 'blackMarket'; + ctx.auction = null; + ctx.richeseAuction = { + event: `native-${origin}-auction`, cardId: card.id, source: origin, owner: 'rival', + outcome: { kind: 'sold', winner: 'buyer', amount: 7 }, + }; + ctx.sale = { winner: 'buyer', amount: 7, free: false, origin, seller: 'rival' }; + ctx.players = [{ ...ctx.players[0], hand: [{ ...older }, { ...card }] }, ctx.players[1]]; + ctx.physicalCards = [{ ...older }, { ...card }, { ...top }]; + return ctx; +} + +void test('native cache and Black Market purchases close exact delivered cards once for Use or Pass', () => { + for (const origin of ['cache', 'blackMarket'] as const) { + const ctx = nativeContext(origin); + const original = source(ctx); + const delivered = ctx.players[0].hand.find(card => card.id === ctx.richeseAuction!.cardId)!; + assert.deepEqual(original.card, delivered); + assert.equal(original.amount, 7); + assert.equal(original.free, false); + assert.equal(original.auctionIndex, 0); + validateIxianReplacementSource(ctx, JSON.parse(JSON.stringify(original)), parent); + for (const outcome of ['use', 'pass'] as const) { + const closed = closeIxianReplacementSource( + original, outcome, outcome === 'use' ? top : null, initialIxianReplacementCursor(), + ); + assert.equal(closed.receipt.outcome, outcome); + assert.deepEqual(closed.receipt.replacement, outcome === 'use' ? top : null); + assert.throws(() => closeIxianReplacementSource(original, 'pass', null, closed.cursor), IxianReplacementError); + assert.throws(() => closeIxianReplacementSource(original, 'use', top, closed.cursor), IxianReplacementError); + // A later census need not retain the purchased or drawn card at its old location. + validateIxianReplacementHistory( + JSON.parse(JSON.stringify([closed.receipt])), closed.cursor, [older], + ); + } + } +}); + +void test('successive single native lots use the independent sequence without inventing normal rows', () => { + const cache = nativeContext('cache'); + const first = closeIxianReplacementSource(source(cache), 'pass', null, initialIxianReplacementCursor()); + const blackMarket = nativeContext('blackMarket'); + blackMarket.sequence = first.cursor.sequence; + const second = closeIxianReplacementSource(source(blackMarket), 'use', top, first.cursor); + assert.equal(first.receipt.source.auctionIndex, 0); + assert.equal(second.receipt.source.auctionIndex, 0); + assert.notEqual(first.receipt.source.event, second.receipt.source.event); + validateIxianReplacementHistory([first.receipt, second.receipt], second.cursor); +}); + +void test('native purchase authority rejects missing, open, unbid, mismatched and competing lots', () => { + for (const origin of ['cache', 'blackMarket'] as const) { + const ctx = nativeContext(origin); + const lot = ctx.richeseAuction!; + const original = source(ctx); + const variants: IxianReplacementContext[] = [ + { ...ctx, richeseAuction: null }, + { ...ctx, richeseAuction: { ...lot, outcome: null } }, + { ...ctx, richeseAuction: { ...lot, outcome: { kind: 'unbid' } } }, + { ...ctx, richeseAuction: { ...lot, outcome: { kind: 'sold', winner: 'buyer', amount: 0 } } }, + { ...ctx, richeseAuction: { ...lot, outcome: { kind: 'sold', winner: 'rival', amount: 7 } } }, + { ...ctx, richeseAuction: { ...lot, outcome: { kind: 'sold', winner: 'buyer', amount: 8 } } }, + { ...ctx, richeseAuction: { ...lot, source: origin === 'cache' ? 'blackMarket' : 'cache' } }, + { ...ctx, richeseAuction: { ...lot, cardId: unsold.id } }, + { ...ctx, richeseAuction: { ...lot, owner: 'unseated' }, sale: { ...ctx.sale!, seller: 'unseated' } }, + { ...ctx, sale: { ...ctx.sale!, seller: null } }, + { ...ctx, sale: { ...ctx.sale!, seller: 'buyer' } }, + { ...ctx, sale: { ...ctx.sale!, amount: 8 } }, + { ...ctx, sale: { ...ctx.sale!, free: true } }, + { ...ctx, sale: { ...ctx.sale!, origin: 'normal', seller: null } }, + { ...ctx, sale: null }, + { ...ctx, auction: context().auction }, + { ...ctx, expansions: [] }, + { ...ctx, richese: false }, + ...(origin === 'blackMarket' ? [{ ...ctx, advanced: false }] : []), + ]; + for (const invalid of variants) { + assert.throws(() => source(invalid), IxianReplacementError); + assert.throws(() => validateIxianReplacementSource(invalid, original, parent), IxianReplacementError); + } + // Another canonical card already in the hand cannot replace the actual lot. + const swapped = { ...ctx, richeseAuction: { ...lot, cardId: older.id } }; + assert.throws(() => validateIxianReplacementSource(swapped, original, parent), IxianReplacementError); + const absent = { + ...ctx, players: [{ ...ctx.players[0], hand: [older] }, ctx.players[1]], + }; + assert.throws(() => source(absent), IxianReplacementError); + const duplicate = { + ...ctx, players: [{ ...ctx.players[0], hand: [...ctx.players[0].hand, original.card] }, ctx.players[1]], + }; + assert.throws(() => source(duplicate), IxianReplacementError); + } +}); + +void test('normal purchases preserve their actual nonzero lot index and saved continuation', () => { + const ctx = context(); + ctx.auction = { ...ctx.auction!, cards: [unsold, purchased], index: 1 }; + const original = createIxianReplacementSource( + ctx, ixianReplacementEvent(ctx.turn, 'buyer', ctx.sequence, 1), parent, + ); + assert.equal(original.auctionIndex, 1); + validateIxianReplacementSource(ctx, JSON.parse(JSON.stringify(original)), parent); + const closed = closeIxianReplacementSource(original, 'pass', null, initialIxianReplacementCursor()); + validateIxianReplacementHistory(JSON.parse(JSON.stringify([closed.receipt])), closed.cursor); +}); + void test('retained purchases keep saved receipt history linear through the game horizon', () => { const physical = baseDeck(); const history: IxianReplacementReceipt[] = []; diff --git a/tests/richese-ixian-replacement.test.ts b/tests/richese-ixian-replacement.test.ts index f68abee7..6f10a077 100644 --- a/tests/richese-ixian-replacement.test.ts +++ b/tests/richese-ixian-replacement.test.ts @@ -4,7 +4,7 @@ import { applyAction, viewGame, type Action, type Game } from '../game/engine'; import { richeseCards } from '../game/richese-cards'; import { settleIxianReplacementFixture } from './fixture-nexus-ixian-replacement'; import { - createRicheseIxianReplacementFixture, nextRicheseIxianReplacementNativeStep, + createRicheseIxianReplacementFixture, type RicheseIxianReplacementFixture, } from './fixture-richese-ixian-replacement'; @@ -121,52 +121,3 @@ void test(`${advanced ? 'Advanced' : 'Basic'} native final-lot CHOAM purchase cl assert.equal(use.nexusCards!.cards!.hands[f.buyer], null); }); -for (const advanced of [false, true]) -void test(`${advanced ? 'Advanced' : 'Basic'} Richese cache lot keeps the Nexus replacement gated to normal purchases`, () => { - const f = fixtureFor(advanced, 'atreides', true); - const offer = viewGame(f.game, f.buyer).nexusIxianReplacement!; - const richese = f.game.players.find(player => player.faction === 'richese')!.id; - // Keep the purchased card and the unallied Nexus card, so the buyer is still - // an eligible Nexus holder when the round reaches its own compulsory cache lot. - let game = settleIxianReplacementFixture(applyAction(reload(f.game), f.buyer, - { type: 'nexusIxianReplacementPass', event: offer.event })); - assert.equal(game.nexusCards!.cards!.hands[f.buyer], 'ixians'); - assert.equal(game.auction, null); - assert.equal(game.richeseBidding!.stage, 'cacheOffer'); - assert.deepEqual(game.decision, { kind: 'richeseCache', player: richese }); - assert.equal(game.pendingNexusIxianReplacement, null); - const cacheCard = game.richeseCache![0]; - const before = { buyer: owner(game, f.buyer).spice, richese: owner(game, richese).spice, - emperor: owner(game, f.emperor!).spice }; - game = applyAction(game, richese, { type: 'decision', event: game.richeseBidding!.event, - card: cacheCard.id, method: 'onceAround', direction: 'clockwise' }); - assert.equal(game.richeseAuction?.source, 'cache'); - assert.equal(game.richeseAuction?.cardId, cacheCard.id); - assert.equal(game.richeseAuction?.owner, richese); - // A real Once Around: the native policy declines for every other seat, the - // unallied Nexus holder buys the cache card with its own spice. - for (let count = 0; game.richeseAuction && !game.richeseAuction.outcome && count < 20; count++) { - const lot: NonNullable = game.richeseAuction; - const next = nextRicheseIxianReplacementNativeStep(game); - assert.equal(next.actor, lot.active); - assert.equal(game.pendingNexusIxianReplacement, null); - game = next.actor === f.buyer - ? applyAction(game, f.buyer, { type: 'richeseBid', event: lot.event, - amount: lot.bid === 0 ? 1 : null }) - : applyAction(game, next.actor, next.action); - } - // The cache purchase retires its own lot with no replacement frame, while the - // same buyer's normal purchase above did open one. - assert.equal(game.richeseAuction, null); - assert.equal(game.pendingNexusIxianReplacement, null); - assert.equal(game.decision, null); - assert.equal(game.currentAuctionSale, null); - assert.ok(owner(game, f.buyer).hand.some(card => card.id === cacheCard.id)); - assert.ok(!game.richeseCache!.some(card => card.id === cacheCard.id)); - assert.equal(owner(game, f.buyer).spice, before.buyer - 1); - assert.equal(owner(game, richese).spice, before.richese + 1); - assert.equal(owner(game, f.emperor!).spice, before.emperor); - assert.equal(game.nexusCards!.cards!.hands[f.buyer], 'ixians'); - assert.equal(game.richeseBidding!.stage, 'complete'); - assert.equal(game.phase, 4); -}); diff --git a/tests/richese-ixian-special-replacement.test.ts b/tests/richese-ixian-special-replacement.test.ts new file mode 100644 index 00000000..883df52c --- /dev/null +++ b/tests/richese-ixian-special-replacement.test.ts @@ -0,0 +1,174 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, viewGame, type Game } from '../game/engine'; +import { + createRicheseIxianSpecialReplacementFixture, settleRicheseIxianSpecialReplacementSuffix, + type RicheseIxianSpecialReplacementOrigin, type RicheseIxianSpecialReplacementSaleFixture, + type RicheseIxianSpecialReplacementUnbidFixture, +} from './fixture-richese-ixian-special-replacement'; + +const owner = (game: Game, id: string) => game.players.find(player => player.id === id)!; + +const BUILT: Record = {}; +/** One genuine native run per source/mode; each test reloads its own copy. */ +function saleFixture( + origin: RicheseIxianSpecialReplacementOrigin, + advanced: boolean, +): RicheseIxianSpecialReplacementSaleFixture { + const key = `${origin}:${advanced}`; + const held = BUILT[key]; + if (held) return held; + const created = createRicheseIxianSpecialReplacementFixture({ origin, advanced }); + if (created.mode !== 'sold') throw new Error('The paid native source sale is missing.'); + BUILT[key] = created; + return created; +} + +const UNBID: Partial> = {}; +function unbidFixture(origin: RicheseIxianSpecialReplacementOrigin): RicheseIxianSpecialReplacementUnbidFixture { + const held = UNBID[origin]; + if (held) return held; + const created = createRicheseIxianSpecialReplacementFixture({ origin, mode: 'unbid' }); + if (created.mode !== 'unbid') throw new Error('The genuine unbid outcome is missing.'); + UNBID[origin] = created; + return created; +} + +const SOURCES: [RicheseIxianSpecialReplacementOrigin, boolean][] = [ + ['cache', false], ['cache', true], ['blackMarket', true], +]; + +for (const [origin, advanced] of SOURCES) + void test(`${advanced ? 'Advanced' : 'Basic'} native ${origin} Once Around purchase is already paid when its purchased-card offer opens`, () => { + const f = saleFixture(origin, advanced); + // The public offer binds the exact concealed/delivered native card, never a + // top deck card and never an unsold lot card. + const offer = viewGame(f.game, f.buyer).nexusIxianReplacement!; + assert.equal(offer.origin, origin); + assert.equal(offer.canPass, true); + assert.equal(offer.canUse, true); + assert.deepEqual(offer.purchased, f.purchased); + // The genuine payment is already debited exactly once; the seller is not yet + // credited and no Emperor gift exists for this unallied native sale. + assert.equal(owner(f.game, f.buyer).spice, f.beforeSpice - f.bid); + assert.equal(owner(f.game, f.seller).spice, owner(f.beforePurchase, f.seller).spice); + assert.equal(owner(f.game, f.emperor!).spice, owner(f.beforePurchase, f.emperor!).spice); + assert.equal(f.game.nexusCards!.cards!.hands[f.buyer], 'ixians'); + assert.deepEqual(owner(f.game, f.buyer).hand, + [...owner(f.beforePurchase, f.buyer).hand, f.purchased]); + if (origin === 'cache') { + assert.deepEqual(f.game.richeseCache, + f.cacheBefore.filter(card => card.id !== f.purchased.id)); + assert.equal(owner(f.beforePurchase, f.seller).hand.some(card => card.id === f.purchased.id), false); + } else { + assert.deepEqual(f.game.richeseCache, f.cacheBefore, + 'A Black Market sale never touches the compulsory cache.'); + assert.equal(owner(f.beforePurchase, f.seller).hand.some(card => card.id === f.purchased.id), true); + assert.equal(owner(f.game, f.seller).hand.some(card => card.id === f.purchased.id), false); + } + }); + +for (const [origin, advanced] of SOURCES) + void test(`${advanced ? 'Advanced' : 'Basic'} native ${origin} Use spends the Ixian Nexus on that card and leaves the original native suffix intact`, () => { + const f = saleFixture(origin, advanced); + const offer = viewGame(f.game, f.buyer).nexusIxianReplacement!; + const used = settleRicheseIxianSpecialReplacementSuffix(applyAction(structuredClone(f.game), f.buyer, + { type: 'nexusIxianReplacementUse', event: offer.event }), origin); + // Only the purchased native card is discarded and only the actual top card + // is drawn; the paid purchase is never re-debited or credited. + assert.deepEqual(owner(used, f.buyer).hand, + [...owner(f.game, f.buyer).hand.filter(card => card.id !== f.purchased.id), f.nextCard]); + assert.deepEqual(used.discard, [...f.game.discard, f.purchased]); + assert.deepEqual(used.deck, f.game.deck.slice(1)); + assert.equal(used.nexusCards!.cards!.hands[f.buyer], null); + assert.deepEqual(used.nexusCards!.cards!.discard, + [...f.game.nexusCards!.cards!.discard, 'ixians']); + assert.equal(owner(used, f.buyer).spice, f.paidSpice); + assert.equal(owner(used, f.seller).spice, f.sellerSpiceAtOffer + f.bid); + assert.equal(owner(used, f.emperor!).spice, f.emperorSpiceAtOffer); + assert.equal(used.pendingNexusIxianReplacement, null); + assert.equal(used.currentAuctionSale, null); + assert.equal(used.richeseAuction, null); + assert.equal(used.richeseBidding!.blackMarketSold, origin === 'blackMarket'); + if (origin === 'cache') { + assert.deepEqual(used.richeseCache, f.game.richeseCache); + assert.equal(used.richeseBidding!.stage, 'complete'); + assert.equal(used.decision, null); + assert.equal(used.phase, 4); + } else { + assert.deepEqual(used.richeseCache, f.cacheBefore); + assert.equal(used.richeseBidding!.stage, 'declaration'); + assert.deepEqual(used.decision, { kind: 'richeseDeclaration', player: f.seller }); + } + }); + +for (const [origin, advanced] of SOURCES) + void test(`${advanced ? 'Advanced' : 'Basic'} native ${origin} Keep retains the exact purchased card and the Ixian Nexus while the original seller is credited once`, () => { + const f = saleFixture(origin, advanced); + const offer = viewGame(f.game, f.buyer).nexusIxianReplacement!; + const kept = settleRicheseIxianSpecialReplacementSuffix(applyAction(structuredClone(f.game), f.buyer, + { type: 'nexusIxianReplacementPass', event: offer.event }), origin); + assert.deepEqual(owner(kept, f.buyer).hand, owner(f.game, f.buyer).hand); + assert.ok(owner(kept, f.buyer).hand.some(card => card.id === f.purchased.id)); + assert.deepEqual(kept.deck, f.game.deck); + assert.deepEqual(kept.discard, f.game.discard); + assert.deepEqual(kept.nexusCards!.cards!.hands[f.buyer], f.game.nexusCards!.cards!.hands[f.buyer]); + assert.equal(kept.nexusCards!.cards!.hands[f.buyer], 'ixians'); + assert.equal(owner(kept, f.buyer).spice, f.paidSpice); + assert.equal(owner(kept, f.seller).spice, f.sellerSpiceAtOffer + f.bid); + assert.equal(owner(kept, f.emperor!).spice, f.emperorSpiceAtOffer); + assert.equal(kept.pendingNexusIxianReplacement, null); + assert.equal(kept.currentAuctionSale, null); + assert.equal(kept.richeseAuction, null); + assert.deepEqual(kept.richeseCache, + origin === 'cache' ? f.game.richeseCache : f.cacheBefore); + if (origin === 'cache') { + assert.equal(kept.richeseBidding!.stage, 'complete'); + assert.equal(kept.decision, null); + assert.equal(kept.phase, 4); + assert.equal(kept.richeseBidding!.blackMarketSold, false); + } else { + assert.equal(kept.richeseBidding!.stage, 'declaration'); + assert.deepEqual(kept.decision, { kind: 'richeseDeclaration', player: f.seller }); + assert.equal(kept.richeseBidding!.blackMarketSold, true); + } + }); + +void test('Basic native unbid cache lot is not a purchase: the owner keeps the card with no spice, Nexus spend or offer', () => { + const f = unbidFixture('cache'); + // The compulsory cache lot really ran its own Once Around and every genuine + // offer was zero, so the lot retired without a sale. + assert.equal(f.game.richeseAuction, null); + assert.equal(f.game.pendingNexusIxianReplacement, null); + assert.notEqual(f.game.decision?.kind, 'nexusIxianReplacement'); + assert.equal(f.game.currentAuctionSale, null); + assert.equal(f.game.richeseBidding!.stage, 'complete'); + assert.equal(f.game.richeseBidding!.blackMarketSold, false); + // A Keep is not a purchase: no spice moved anywhere and no Emperor gift exists. + for (const seat of f.game.players) + assert.equal(owner(f.game, seat.id).spice, owner(f.beforeFinalAction, seat.id).spice); + assert.equal(f.game.nexusCards!.cards!.hands[f.buyer], 'ixians'); + // The kept card left the original cache into the owner's own hand. + assert.equal(f.game.richeseCache!.some(card => card.id === f.card.id), false); + assert.equal(owner(f.game, f.seller).hand.some(card => card.id === f.card.id), true); + assert.equal((f.game.richeseRemoved ?? []).some(card => card.id === f.card.id), false); + assert.deepEqual(f.game.richeseCache, f.cacheBefore.filter(card => card.id !== f.card.id)); +}); + +void test('Advanced native unbid Black Market lot retains its concealed card with no sale, payment or offer', () => { + const f = unbidFixture('blackMarket'); + assert.equal(f.game.richeseAuction, null); + assert.equal(f.game.pendingNexusIxianReplacement, null); + assert.equal(f.game.currentAuctionSale, null); + assert.equal(f.game.richeseBidding!.blackMarketSold, false); + assert.equal(f.game.richeseBidding!.stage, 'declaration'); + assert.deepEqual(f.game.decision, { kind: 'richeseDeclaration', player: f.seller }); + for (const seat of f.game.players) + assert.equal(owner(f.game, seat.id).spice, owner(f.beforeFinalAction, seat.id).spice); + assert.equal(f.game.nexusCards!.cards!.hands[f.buyer], 'ixians'); + assert.ok(owner(f.game, f.seller).hand.some(card => card.id === f.card.id)); + assert.ok(f.keptCacheCards.includes(f.card.id)); + assert.deepEqual(f.game.richeseCache, f.cacheBefore, + 'An unbid Black Market lot must not consume a compulsory cache card.'); +}); + diff --git a/tools/start-prototype.ts b/tools/start-prototype.ts index 509e7e75..f00c64c3 100644 --- a/tools/start-prototype.ts +++ b/tools/start-prototype.ts @@ -25,7 +25,7 @@ async function main() { }); if (values.help) { console.log( - "Usage: node --import tsx tools/start-prototype.ts --profile ix|discovery|homeworld-occupation|leader-skills|banker-income|factions|stronghold-factions|nexus|moritani-assassinate|ecaz-treachery|ecaz-occupy|semuta|kull|richese-betrayal|nexus-kull|guild-betrayal|ixian-replacement|ixian-betrayal|harkonnen-betrayal --db PATH --room CODE --version NUMBER --out /private/new-directory\nStarts only a fresh ready lobby for the selected development prototype. Stronghold-factions opts a fresh ready 2–6-seat Advanced roster with at least one selected native Ixians, Tleilaxu, CHOAM or Richese faction and otherwise classic opponents into Stronghold Cards, optionally preserving unused Tech Tokens at 3–6 seats. Select one or both distinct Ix/CHOAM decks; each native faction requires its family deck. The selected decks supply 47 Ix or combined, or 35 CHOAM canonical physical Treachery Cards, with a separate ten-card Richese cache when Richese plays. Native setup choices, HMS placement, Face Dancers, Richese cache and CHOAM Auditor remain genuine; custody starts unowned and is claimed by actual END Mentat. It does not redeal, reset or retrofit a saved game. Banker-income opts a fresh 2–6-seat classic Basic/Advanced base-deck roster, supported Basic Ixian/Tleilaxu/CHOAM/Moritani skill roster with its single required deck, or Advanced native Ixians, Tleilaxu and/or CHOAM plus classic roster with distinct required family decks, into automatic deferred normal income and native Mentat collection with all fourteen Leader Skills. Original bank payer costs exclude the separate Tleilaxu free-revival award; already-earned spice stays with its original faction under the existing provisional policy. Nexus enables the existing private Nexus setup. Moritani-assassinate explicitly opts into the bounded Advanced assassination preview. Semuta opts a fresh Richese roster into bounded clean-discard reactions; an included Ix deck supplies physical Thumper without opening public starts. Kull opts a fresh eligible CHOAM roster into the user-selected Karama interception preview. Richese-betrayal opts a fresh paired CHOAM/Richese Nexus roster into cache-purchase veto and sale-bank diversion. Nexus-kull opts a fresh CHOAM-plus-classic roster with CHOAM/Ix decks and Nexus into source-aware printed or any-Treachery-fueled Cunning Kull. Guild-betrayal opts a fresh classic roster with native Guild into full funded shipment-payment replacement; it preserves an explicitly enabled Homeworld module for Junction routes. Ixian-replacement opts fresh classic/base33, native Tleilaxu plus classics/Ix47 without optional Sandtrout, or native Richese plus classics/CHOAM35 with the separate original ten-card cache, in Basic/Advanced with Nexus alone. Only normal-auction purchased-card replacement is connected; cache, Black Market and special-purchase origins remain excluded. Native Ixians and special purchase sources remain unsupported. Harkonnen uses the visible provisional replacement-before-income/bonus order. Ixian-betrayal opts a fresh 2–6-seat Basic/Advanced native Ixian plus classic/Tleilaxu roster with Ix 47-card Treachery and Nexus only into prevention of one actual native Bidding draw or Advanced Technology exchange, after native Karama counters. Harkonnen-betrayal opts a fresh 2–6-seat classic Basic/Advanced roster with native Harkonnen, base 33-card Treachery and Nexus alone into cancellation of an actual declared personal or allied Harkonnen Traitor Card after native counters, physical retirement and shuffle, and one private actual Mentat replacement. Modules, overlays, public starts and old-game retrofits remain excluded except for each profile’s documented composition. Nexus is not dealt initially: a qualifying alliance requires at least three seats; two unallied receivers require four. Backs up all rooms first, preserves sessions and existing games, and rejects stale versions. Normal game-start and publication gates remain closed.", + "Usage: node --import tsx tools/start-prototype.ts --profile ix|discovery|homeworld-occupation|leader-skills|banker-income|factions|stronghold-factions|nexus|moritani-assassinate|ecaz-treachery|ecaz-occupy|semuta|kull|richese-betrayal|nexus-kull|guild-betrayal|ixian-replacement|ixian-betrayal|harkonnen-betrayal --db PATH --room CODE --version NUMBER --out /private/new-directory\nStarts only a fresh ready lobby for the selected development prototype. Stronghold-factions opts a fresh ready 2–6-seat Advanced roster with at least one selected native Ixians, Tleilaxu, CHOAM or Richese faction and otherwise classic opponents into Stronghold Cards, optionally preserving unused Tech Tokens at 3–6 seats. Select one or both distinct Ix/CHOAM decks; each native faction requires its family deck. The selected decks supply 47 Ix or combined, or 35 CHOAM canonical physical Treachery Cards, with a separate ten-card Richese cache when Richese plays. Native setup choices, HMS placement, Face Dancers, Richese cache and CHOAM Auditor remain genuine; custody starts unowned and is claimed by actual END Mentat. It does not redeal, reset or retrofit a saved game. Banker-income opts a fresh 2–6-seat classic Basic/Advanced base-deck roster, supported Basic Ixian/Tleilaxu/CHOAM/Moritani skill roster with its single required deck, or Advanced native Ixians, Tleilaxu and/or CHOAM plus classic roster with distinct required family decks, into automatic deferred normal income and native Mentat collection with all fourteen Leader Skills. Original bank payer costs exclude the separate Tleilaxu free-revival award; already-earned spice stays with its original faction under the existing provisional policy. Nexus enables the existing private Nexus setup. Moritani-assassinate explicitly opts into the bounded Advanced assassination preview. Semuta opts a fresh Richese roster into bounded clean-discard reactions; an included Ix deck supplies physical Thumper without opening public starts. Kull opts a fresh eligible CHOAM roster into the user-selected Karama interception preview. Richese-betrayal opts a fresh paired CHOAM/Richese Nexus roster into cache-purchase veto and sale-bank diversion. Nexus-kull opts a fresh CHOAM-plus-classic roster with CHOAM/Ix decks and Nexus into source-aware printed or any-Treachery-fueled Cunning Kull. Guild-betrayal opts a fresh classic roster with native Guild into full funded shipment-payment replacement; it preserves an explicitly enabled Homeworld module for Junction routes. Ixian-replacement opts fresh classic/base33, native Tleilaxu plus classics/Ix47 without optional Sandtrout, or native Richese plus classics/CHOAM35 with the separate original ten-card cache, in Basic/Advanced with Nexus alone. Normal and genuine Richese cache purchases are connected in Basic/Advanced; Black Market remains Advanced-only. Cache uses the visible provisional independent-Nexus grant interpretation; the native Ixian ally cache exclusion remains unchanged. Native Ixians and direct Richese special-Karama purchase sources remain unsupported. Harkonnen uses the visible provisional replacement-before-income/bonus order. Ixian-betrayal opts a fresh 2–6-seat Basic/Advanced native Ixian plus classic/Tleilaxu roster with Ix 47-card Treachery and Nexus only into prevention of one actual native Bidding draw or Advanced Technology exchange, after native Karama counters. Harkonnen-betrayal opts a fresh 2–6-seat classic Basic/Advanced roster with native Harkonnen, base 33-card Treachery and Nexus alone into cancellation of an actual declared personal or allied Harkonnen Traitor Card after native counters, physical retirement and shuffle, and one private actual Mentat replacement. Modules, overlays, public starts and old-game retrofits remain excluded except for each profile’s documented composition. Nexus is not dealt initially: a qualifying alliance requires at least three seats; two unallied receivers require four. Backs up all rooms first, preserves sessions and existing games, and rejects stale versions. Normal game-start and publication gates remain closed.", '\nSemuta additionally admits a fresh Basic roster with native Richese and Moritani, classic opponents, exactly CHOAM and Ecaz decks, and no optional modules. Native setup remains genuine. Private Sabotage and Robbery overflow discards may offer Semuta before a clean ordinary shipment/movement arrival resumes. The victim/draw and original payment/forces are already committed; candidate faces stay hidden until commitment. Advanced Moritani, native Ecaz, Ix/E3 mixtures, worm/Ambassador/overlap arrivals and public starts remain outside this entry.', '\nSemuta with CHOAM and optional Ix preserves original unused Tech Tokens in fresh 3–6-seat Basic/Advanced Richese lobbies under existing native prerequisites. Advanced additionally retains original unused Stronghold Cards at2–6; both modules require3–6. Native setup, first-Storm Tech and final-Mentat Stronghold assignment remain original. Mandatory/winner discards react before the original winner takes one loser-owned token, before Face Dance. Closing Gamont reacts before the original CHOAM finish and changed-board claim. Optional original skills require --leader-skills as described below; other modules, the separate CHOAM/Ecaz Moritani entry, retrofit and public starts remain excluded.', '\n--profile semuta --discoveries uses original fresh Richese/CHOAM and optional Ix Discovery7/8 setup, Basic/Advanced2–6, with original unused Tech3+/Advanced Strongholds. Shrine physical roles stay distinct: Truthtrance may be played as Karama while its printed identity remains; Semuta recovers the used physical cost without replaying its effect. No Skills, Homeworlds, Nexus, E3, played-save conversion or public-mode gate is opened.',