diff --git a/components/nexus-ixian-replacement.tsx b/components/nexus-ixian-replacement.tsx index 60ddf35f..1fca2bab 100644 --- a/components/nexus-ixian-replacement.tsx +++ b/components/nexus-ixian-replacement.tsx @@ -33,18 +33,24 @@ export function NexusIxianReplacement({ game, act, busy }: { return (

Purchased card choice

-

{buyer?.name ?? 'The buyer'} chooses whether to keep the completed purchase before the auction continues.

+

{buyer?.name ?? 'The buyer'} chooses whether to keep the completed purchase before the original continuation resumes.

This neutral development-preview boundary is offered independently of the buyer’s private Nexus face. 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' && ( + {(offer.origin === 'cache' || offer.origin === 'richeseSpecial') && (

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.

)} + {offer.origin === 'richeseSpecial' && ( +

+ The original Karama cost, three-spice payment and once-game power are already committed. + This choice resumes the original income and controls; it creates no new auction or payment. +

+ )} {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 b0888312..ebd3b753 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -37,22 +37,24 @@ Purchased-card replacement now composes: - original Tleilaxu/Ix47 setup and native purchases in Basic **and Advanced**; - 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. + Advanced, using the real sold lot and original price/continuation; +- Advanced direct special-Karama acquisition during Bidding, using its own + committed price3/cache target rather than an unrelated row or older income. 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. +Actual controls show that policy. Latest eleven-file107/107, types/lint/build, +ten original direct runtime paths, sixteen legal policy actions and two390px +native-action journeys pass. An already-paid bid no longer holds spice twice; +genuinely unpaid commitments stay reserved. Direct Use/Keep preserves original +cost/once-use, active player, income and suspended controls. Native automatic +payment may open a distinct following normal offer, not replay the old source. +Other-phase special acquisition keeps its native behavior without Nexus. +Receipt v1 shapes and ordinary parents remain unchanged. Earlier evidence +and exact policies are in the [canonical contract](NEXUS_IXIAN_REPLACEMENT_RULES.md). +All five facets stay Partial. Other modules and final assurance remain +separate. No human server, saved room, D1, deployment or public gate changed. +Current CLI help, source anchors and26 topic evidence paths resolve. **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 1cec8a42..571319e5 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,36 @@ # Dune implementation status +## 16 October 2026 — Direct Richese special purchase and paid-bid funding + +Connected actual Advanced special-Karama cache acquisition during Bidding +through its own committed price3/target/Karama/once-use receipt. An unrelated +row, sale or older income supplies no substitute authority. Native active +player and controls suspend/restore exactly; existing v1 receipts/ordinary +parents remain unchanged. Other phases keep native acquisition without a +Bidding Nexus offer. No auction, payment or original quote is recreated. + +The genuine nested-income path found a pre-existing defect: uncommittedSpice +reserved a normal bid again after currentAuctionSale had consumed it. +Only unpaid bids are now held. Ten new direct scenarios and the affected +eleven-file107/107 pass, with existing Karama/bribe funding consumers, +types/lint and production build (module-register/chunk/Unknown-route warnings). +Ten original runtime paths and sixteen legal policy actions pass. + +Two actual390px native-action journeys pass. At remaining wallet3, direct +Use buys/replaces Residual Poison with Artillery and preserves the older +Poison Tooth, wallet0/Emperor10→15/one row advance. With wallet5/unpaid bid2, +direct Keep pays3 then allows the original only-funded bid2 automatically: +its distinct normal Nexus offer targets Poison Tooth/price2; keeping that +offer completes wallet0/Emperor15 with Nexus retained. Cost/once-use occur +once and direct/income buffers clear. No overflow/browser/application error. + +Removed an obsolete serialization substring pin without losing semantic +rival-frame/hand checks. No human server, D1, saved room, deployment/public +gate or broad/HTTP/saved-game assurance changed/run. +[Source, funding defect and scoped proof](NEXUS_IXIAN_REPLACEMENT_RULES.md#direct-richese-special-purchase-during-bidding--16-october-2026). +Current CLI help, four source anchors and26 topic evidence paths resolve; +all five facets remain Partial without full-family/deployed certification. + ## 16 October 2026 — Genuine Richese cache and Black Market Ixian replacement Connected the actual positive sold native lot after buyer debit/card delivery diff --git a/docs/NEXUS_CARD_RULES.md b/docs/NEXUS_CARD_RULES.md index 49b18d20..787e8557 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 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. +The [bounded Ixian Secret Ally replacement integration](NEXUS_IXIAN_REPLACEMENT_RULES.md#bounded-local-runtime-contract) connects normal purchases, genuine Richese cache/Advanced Black Market sales and genuine Advanced Richese special-Karama acquisition during Bidding. Its direct receipt binds the actual price3/cache target rather than an unrelated row or older sale; original native income and active/suspended controls resume once. Unpaid obligations stay reserved while already-consumed bids are not held twice. Other phases keep native special acquisition without a Nexus opportunity. Native BG counters and provisional cache/Harkonnen interpretations remain; no native ally cache exclusion or publisher priority is rewritten. Other modules and full/recovery/deployed acceptance stay separate. **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 335672e5..cab9fda9 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) 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. +**Later Ixian Secret Ally replacement prototype:** [NEXUS_IXIAN_REPLACEMENT_RULES.md](NEXUS_IXIAN_REPLACEMENT_RULES.md#bounded-local-runtime-contract) connects normal purchases, positive Richese cache sales in Basic/Advanced, Advanced Black Market and Advanced direct special-Karama acquisition during Bidding. Each binds actual purchase authority and original settlement; direct acquisition suspends/restores active player and controls without a fake row. Other phases keep native special acquisition without a Nexus offer. BG counters and the visible provisional cache/Harkonnen interpretations remain. Unbid retention is not a purchase. Source-qualified rule/control evidence is Partial; other modules, full/recovery/deployed acceptance and public starts remain separate. 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 3fce9570..a9fc7549 100644 --- a/docs/NEXUS_IXIAN_REPLACEMENT_RULES.md +++ b/docs/NEXUS_IXIAN_REPLACEMENT_RULES.md @@ -2,6 +2,60 @@ 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. +## Direct Richese special purchase during Bidding — 16 October 2026 + +An actual Advanced Richese special-Karama cache acquisition during Bidding +now supplies its own committed purchase authority. The original printed +Karama is discarded, three spice paid, selected cache card delivered and +once-use committed **before** the independent Nexus choice. This is the +same selected independent-cache interpretation, not a fabricated auction +or another use of the special power. Other phases retain native acquisition +without a Bidding-only Nexus opportunity; Basic special powers are not added. + +The optional direct receipt binds buyer, cache target, original Karama, +amount3, turn and phase3. It takes precedence over an unrelated normal sale +or native lot. Original active player, response, decision and pending Karama +are suspended exactly, including applicable new purchase income and any +older control it wraps. Use/Keep retires only this receipt, then original +income and controls resume. Existing v1 purchase/closed receipts remain +unchanged; normal/native parents gain no new keys without a direct receipt. + +The nested-income path exposed an existing funding defect: a normal bid was +still reserved after its delivered `currentAuctionSale` had already consumed +it. `uncommittedSpice` now reserves only unpaid normal bids. The actual +remaining three spice can buy the special cache card, while a genuinely +unpaid bid2 still stays reserved. No payment is waived, refunded or replayed. + +Ten new direct scenario cases and the affected eleven-file **107/107** union +pass, including existing Karama-intent/bribe budget consumers. Types/lint and +production build pass with module-register, large-chunk and Unknown-route +warnings. Ten original runtime paths cover Use/Keep, Emperor/bank, reserved +bid, nested income and actual Revival exclusion; sixteen legal policy +actions pass. All cards, clocks, wallets, Nexus and suspended controls come +from original actions; only still-undealt deck order is selected. + +Two actual styled390×844 journeys begin **before** the native special action. +The already-paid income scene exposes an enabled three-spice purchase at +wallet3, then direct Use exchanges Residual Poison for Artillery and preserves +the older Poison Tooth: wallet0, Emperor10→15, one row advance. The unpaid +bid scene keeps bid2 reserved at wallet5, pays direct3, then direct Keep lets +the original funded bid2 complete automatically. Its distinct normal offer +binds Poison Tooth/price2, not the old direct target/price3. Keeping that +offer finishes wallet0/Emperor15 with both original acquisitions and Nexus +retained. Power use and printed cost occur once. Both direct/income buffers +clear. Width/scroll390 and no browser/application error. + +An obsolete whole-serialization identifier-substring assertion was deleted; +semantic rival-frame/hand privacy assertions remain. No new broad, HTTP or +saved/deployed assurance ran. Only an owned memory-only loopback host ran; +no human server, D1, saved room, deployment or public gate changed. Proof: +`/tmp/dune-richese-direct-ixian-runtime.json`, +`/tmp/dune-richese-direct-ixian-ui-proof.json`. All five facets stay Partial. + + +Current CLI help, four current source anchors and26 topic evidence paths +resolve without reference parse diagnostics; all five facets remain Partial. + ## Genuine cache and Black Market purchases — 16 October 2026 The existing fresh Richese/CHOAM profile now binds actual positive sold @@ -17,8 +71,8 @@ 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. +purchase. Direct special-Karama acquisitions were still separate at that +checkpoint; their later Bidding-phase composition is recorded above. 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 @@ -248,7 +302,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 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). +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 native `ixAllyCard` cache filter remains unchanged, not imported as Nexus authority. The same literal purchase analysis now connects genuine Advanced Richese special-Karama acquisition during Bidding through its real committed receipt, without inventing an auction. Outside Bidding that native acquisition gains no Nexus opportunity. 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) @@ -275,7 +329,7 @@ and never add seats to an admitted game or fabricate Nexus hands. ### Connected purchase and choice -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. +1. Connect actual normal-auction purchases, positive sold Richese cache lots (Basic/Advanced), Black Market lots (Advanced) and genuine Advanced Richese special-Karama acquisition during Bidding. Native cost/payment and card delivery precede the source-bound choice; original recipient/income/controls/bonus or lot continuation follow once. A direct receipt binds its own target/price3 even while an unrelated row or older income remains pending. Outside Bidding, native special acquisition keeps its original behavior without a Nexus offer. Unbid keeps/retains, gifts, bonus draws, Technology swaps, old hand cards and historical IDs supply no purchase. 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. @@ -393,4 +447,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. -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. +Expansion-card discard reactions, Semuta/Ecaz priority and further 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. Direct special acquisition is connected only during Bidding and preserves its older native controls rather than granting another auction. 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 fc3c216e..ff52cd25 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 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 Secret Ally replacement | Fresh classic/Tleilaxu/Richese profiles, Ixians absent, unallied buyer and Nexus alone. Normal purchases, positive Richese cache/Advanced Black Market sales and genuine Advanced special-Karama cache acquisition during Bidding bind actual target/payment and original continuation. A direct price3 receipt outranks unrelated rows/income, preserving active player, once-use and suspended controls without an auction. Outside Bidding it adds no offer. Only unpaid bids remain reserved; delivered sale commitments are not charged twice. Cache uses the visible provisional independent-Nexus interpretation; native ally exclusion and BG/Harkonnen source rules remain. Other modules and full acceptance stay separate. | [Current contract](NEXUS_IXIAN_REPLACEMENT_RULES.md#bounded-local-runtime-contract), [actual direct receipt, funding fix and proof](NEXUS_IXIAN_REPLACEMENT_RULES.md#direct-richese-special-purchase-during-bidding--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 37aa48bc..cc6492c1 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -56,7 +56,7 @@ import { moritaniTerrorAssassinationPool, moritaniTerrorAssassinationValue } fro import { createNexusGuildSecretAlly, validateNexusGuildSecretAlly, quoteNexusGuildSecretShipment, type NexusGuildSecretAllyReceipt } from './nexus-guild-secret-ally'; import { shipmentAvailable } from './shipment-opportunity'; import { createGuildBetrayalInvoice, createGuildBetrayalReceipt, guildBetrayalEligible, guildBetrayalResponders, validateGuildBetrayalInvoice, validateGuildBetrayalHistory, nexusGuildBetrayalSourceReceipt, type GuildBetrayalInvoice, type GuildBetrayalCursor, type GuildBetrayalReceipt, type GuildBetrayalAuthority } from './nexus-guild-betrayal'; -import { createIxianReplacementSource, validateIxianReplacementSource, closeIxianReplacementSource, validateIxianReplacementHistory, initialIxianReplacementCursor, ixianReplacementEvent, type IxianReplacementSource, type IxianReplacementReceipt, type IxianReplacementCursor } from './nexus-ixian-replacement'; +import { createIxianReplacementSource, validateIxianReplacementSource, closeIxianReplacementSource, validateIxianReplacementHistory, initialIxianReplacementCursor, ixianReplacementEvent, type IxianReplacementSource, type IxianReplacementReceipt, type IxianReplacementCursor, type RicheseSpecialPurchaseReceipt } from './nexus-ixian-replacement'; import { createIxianBetrayalSource, validateIxianBetrayalFrame, initialIxianBetrayalCursor, closeIxianBetrayalSource, validateIxianBetrayalHistory, ixianBetrayalResponders, ixianBetrayalEligible, type IxianBetrayalSource, type IxianBetrayalCursor, type IxianBetrayalReceipt } from './nexus-ixian-betrayal'; import { createHarkonnenBetrayalSource, validateHarkonnenBetrayalFrame, initialHarkonnenBetrayalCursor, closeHarkonnenBetrayalSource, validateHarkonnenBetrayalHistory, harkonnenBetrayalResponders, harkonnenBetrayalEligible, createHarkonnenBetrayalReplacement, validateHarkonnenBetrayalReplacements, drawHarkonnenBetrayalReplacement, type HarkonnenBetrayalSource, type HarkonnenBetrayalCursor, type HarkonnenBetrayalReceipt, type HarkonnenBetrayalReplacement, type HarkonnenBetrayalAuthority } from './nexus-harkonnen-betrayal'; import { createNexusGuildCunning, validateNexusGuildCunning, nexusGuildCunningMoves, type NexusGuildCunningReceipt } from './nexus-guild-cunning'; @@ -1232,9 +1232,12 @@ export type Game = { /** Explicit development profile; never enabled by a public start or player action. */ kullPreview?: boolean; nexusKullPreview?: boolean; - /** Fresh bounded classic Nexus purchased-card prototype; never inferred for old saves. */ + /** Fresh bounded Nexus purchased-card prototype; never inferred for old saves. */ nexusIxianReplacementPreview?: true; pendingNexusIxianReplacement?: IxianReplacementSource | null; + pendingNexusIxianSpecialPurchase?: (RicheseSpecialPurchaseReceipt & { + resume: Pick; + }) | null; nexusIxianReplacementHistory?: IxianReplacementReceipt[]; nexusIxianReplacementCursor?: IxianReplacementCursor; /** Fresh native Ixian attempt prevention; never inferred from held cards. */ @@ -9913,6 +9916,7 @@ function decideIxianNexusBetrayal(g: Game,id: string,action: Action): void { /** Fresh classic, Tleilaxu or Richese profile; no public start or retrofit. */ export function initializeIxianNexusReplacementGameForAudit(state: Game): Game { requireRule(!state.nexusIxianReplacementPreview && !state.pendingNexusIxianReplacement && + !state.pendingNexusIxianSpecialPurchase && state.nexusIxianReplacementHistory === undefined && state.nexusIxianReplacementCursor === undefined && ixianNexusReplacementModeSupported(state), 'Ixian Nexus replacement needs a fresh classic/base, Tleilaxu/Ix or Richese/CHOAM lobby with Nexus alone.'); @@ -9948,20 +9952,25 @@ function ixianNexusReplacementParent(g: Game): string { ...(byFaction(g, 'richese') ? { richeseBidding:g.richeseBidding, richeseCache:g.richeseCache,richeseRemoved:g.richeseRemoved, ...(g.richeseAuction ? { richeseAuction:g.richeseAuction } : {}) } : {}), + ...(g.pendingNexusIxianSpecialPurchase ? { specialPurchase:g.pendingNexusIxianSpecialPurchase, + purchaseIncome:g.pendingRichesePurchaseIncome } : {}), decision:g.decision,response:g.response,phaseOpening:g.phaseOpening}); } function ixianNexusReplacementContext(g: Game) { - requireRule(g.auction || g.richeseAuction, 'The Nexus purchased-card choice lost its original auction.'); + requireRule(g.pendingNexusIxianSpecialPurchase || g.auction || g.richeseAuction, + 'The Nexus purchased-card choice lost its original purchase.'); return {status:g.status,phase:g.phase,turn:g.turn,sequence:g.nexusIxianReplacementCursor!.sequence, advanced:g.advanced,expansions:g.expansions,richese:!!byFaction(g,'richese'), auction:g.auction,richeseAuction:g.richeseAuction ?? null,sale:g.currentAuctionSale ?? null, + specialPurchase:g.pendingNexusIxianSpecialPurchase ?? null, players:g.players.map(p => ({id:p.id,hand:p.hand,handLimit:handLimit(p)})), physicalCards:physicalTreacheryCards(g)}; } function ixianNexusReplacementIntegrity(g: Game): void { const pending = g.pendingNexusIxianReplacement; + const special = g.pendingNexusIxianSpecialPurchase; if (!g.nexusIxianReplacementPreview) { - requireRule(g.nexusIxianReplacementPreview === undefined && !pending && + requireRule(g.nexusIxianReplacementPreview === undefined && !pending && !special && g.nexusIxianReplacementHistory === undefined && g.nexusIxianReplacementCursor === undefined && g.decision?.kind !== 'nexusIxianReplacement', 'The Ixian Nexus replacement lost its explicit fresh profile.'); @@ -9972,47 +9981,72 @@ function ixianNexusReplacementIntegrity(g: Game): void { nexusRule(() => validateIxianReplacementHistory(g.nexusIxianReplacementHistory!, g.nexusIxianReplacementCursor!,physicalTreacheryCards(g))); if (!pending) { - requireRule(g.decision?.kind !== 'nexusIxianReplacement', 'The purchased-card choice lost its source.'); + requireRule(!special && g.decision?.kind !== 'nexusIxianReplacement', 'The purchased-card choice lost its source.'); return; } const buyer = getPlayer(g,pending.buyer); + if (special) requireRule(special.buyer === buyer.id && buyer.faction === 'richese' && + buyer.specialKaramaUsed === true && special.resume && typeof special.resume === 'object', + 'The direct Richese purchase lost its committed native buyer, once-use or original controls.'); requireRule(!buyer.ally && !!g.nexusCards?.cards?.hands[buyer.id] && g.decision?.kind === 'nexusIxianReplacement' && g.decision.player === buyer.id && 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.currentAuctionSale?.origin === 'normal' - ? !g.richeseAuction : !g.auction && !!g.richeseAuction), + !g.pendingTreacheryDiscard && (special || (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))); } function offerIxianNexusReplacement(g: Game): boolean { if (!g.nexusIxianReplacementPreview) return false; + const special = g.pendingNexusIxianSpecialPurchase; const sale = g.currentAuctionSale; - const buyer = sale && getPlayer(g,sale.winner); - if (!sale || !buyer || buyer.ally || !g.nexusCards?.cards?.hands[buyer.id]) return false; + const buyer = special ? getPlayer(g,special.buyer) : sale ? getPlayer(g,sale.winner) : null; + if (!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.'); - 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); + if (!special && sale && sale.origin !== 'normal') + currentAuctionContinuationQuote(g, { kind: 'sale', free: sale.free }); + const event = ixianReplacementEvent(g.turn,buyer.id,g.nexusIxianReplacementCursor!.sequence, + special ? 0 : g.auction?.index ?? 0); g.decision = {kind:'nexusIxianReplacement',player:buyer.id,event}; g.active = buyer.id; g.pendingNexusIxianReplacement = nexusRule(() => createIxianReplacementSource( ixianNexusReplacementContext(g),event,ixianNexusReplacementParent(g))); return true; } +/** The native direct purchase has already paid, delivered and spent its power. */ +function offerRicheseSpecialReplacement(g: Game, buyer: Player, karama: Card, cardId: string): boolean { + if (!g.nexusIxianReplacementPreview || !g.advanced || g.phase !== 3 || + buyer.ally || !g.nexusCards?.cards?.hands[buyer.id]) return false; + requireRule(!g.pendingNexusIxianReplacement && !g.pendingNexusIxianSpecialPurchase && + buyer.faction === 'richese' && buyer.specialKaramaUsed === true, + 'Finish the original purchase before opening another purchased-card choice.'); + g.pendingNexusIxianSpecialPurchase = { + buyer:buyer.id,cardId,karama:karama.id,amount:3,turn:g.turn,phase:3, + resume:{active:g.active,response:g.response,decision:g.decision,pendingKarama:g.pendingKarama}, + }; + g.response = null; + g.decision = null; + g.pendingKarama = null; + requireRule(offerIxianNexusReplacement(g), 'The committed special purchase lost its original Nexus opportunity.'); + return true; +} 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,origin:g.currentAuctionSale!.origin,canPass:own, + return {event:source.event,buyer:source.buyer, + origin:g.pendingNexusIxianSpecialPurchase ? 'richeseSpecial' as const : g.currentAuctionSale!.origin,canPass:own, canUse:own && g.nexusCards?.cards?.hands[id] === 'ixians', blocked:null as string | null,purchased:own ? structuredClone(source.card) : null}; } function decideIxianNexusReplacement(g: Game,id: string,action: Action): void { ixianNexusReplacementIntegrity(g); const source = g.pendingNexusIxianReplacement!; + const special = g.pendingNexusIxianSpecialPurchase; requireRule(source.buyer === id && action.event === source.event && ['nexusIxianReplacementPass','nexusIxianReplacementUse'].includes(action.type) && Object.keys(action).length === 2 && Object.keys(action).every(key => ['type','event'].includes(key)), @@ -10037,7 +10071,13 @@ function decideIxianNexusReplacement(g: Game,id: string,action: Action): void { g.decision = null; if (use) log(g,`${getPlayer(g,id).name} spent Ixian Nexus Secret Ally to discard the purchased Treachery Card and draw a private replacement.`, {faction:getPlayer(g,id).faction,name:'Nexus purchased-card replacement'}); - continueAuctionSale(g,source.free); + if (special) { + g.pendingNexusIxianSpecialPurchase = null; + g.active = special.resume.active; + g.response = special.resume.response; + g.decision = special.resume.decision; + g.pendingKarama = special.resume.pendingKarama; + } else continueAuctionSale(g,source.free); } /** Opt-in fresh classic setup through the real physical Nexus/Homeworld setup. */ export function initializeGuildBetrayalGameForAudit(state: Game): Game { @@ -13057,7 +13097,8 @@ function uncommittedSpice(g: Game, p: Player) { return ( p.spice - richeseOwnCommitment(g.richeseAuction, g.richeseFunding ?? {}, p.id) - - (g.auction?.bidder === p.id + // A current sale already consumed the bid; only an unpaid bid stays reserved. + (g.auction?.bidder === p.id && !g.currentAuctionSale ? g.auction.bid - (g.auction.allyPayment ?? 0) : 0) - (battleSupportCost(g, p, g.battle?.plans[p.id]?.support ?? 0) - @@ -29204,6 +29245,7 @@ function resolveSpecialKaramaEffect(g: Game, p: Player, card: Card, intent: Spec passed: [], }; } + if (!intent.nexusEvent) offerRicheseSpecialReplacement(g, p, card, intent.acquire); } else if (intent.kind === 'choam') { const selected = intent.cards; const entries: FreshDiscardBatch['entries'][number][] | null = g.semutaPreview ? [] : null; diff --git a/game/nexus-ixian-replacement.ts b/game/nexus-ixian-replacement.ts index 0901f529..39aa02c9 100644 --- a/game/nexus-ixian-replacement.ts +++ b/game/nexus-ixian-replacement.ts @@ -4,6 +4,15 @@ import type { RicheseAuction } from './richese-auction'; export class IxianReplacementError extends Error {} +export type RicheseSpecialPurchaseReceipt = { + buyer: string; + cardId: string; + karama: string; + amount: 3; + turn: number; + phase: 3; +}; + export type IxianReplacementContext = { status: string; phase: number; @@ -11,7 +20,7 @@ export type IxianReplacementContext = { sequence: number; expansions: readonly string[]; richese: boolean; - /** Native Black Market bidding only opens in Advanced play. */ + /** Native Black Market bidding and Richese special Karama require Advanced play. */ advanced: boolean; auction: { cards: readonly Card[]; @@ -27,6 +36,7 @@ export type IxianReplacementContext = { origin: 'normal' | 'cache' | 'blackMarket'; seller: string | null; } | null; + specialPurchase: RicheseSpecialPurchaseReceipt | null; players: readonly { id: string; hand: readonly Card[]; @@ -147,6 +157,51 @@ function validatePhysical(physical: readonly Card[], ctx?: IxianReplacementConte ); } +type PurchaseTerms = { + card: Card; + buyer: string; + amount: number; + free: boolean; + auctionIndex: number; +}; + +/** The committed direct receipt takes authority without consulting older auctions. */ +function specialPurchaseTerms(ctx: IxianReplacementContext): PurchaseTerms { + const purchase = ctx.specialPurchase; + requireReplacement( + plain(purchase) && ctx.advanced && ctx.richese && ctx.expansions.includes('choam') && + ctx.phase === 3 && purchase.phase === 3 && purchase.turn === ctx.turn && + whole(purchase.turn) && purchase.turn > 0 && purchase.amount === 3 && + text(purchase.buyer) && text(purchase.cardId) && text(purchase.karama) && + purchase.cardId !== purchase.karama && Object.hasOwn(cache, purchase.cardId), + 'Replacement needs its committed Advanced Richese special Karama purchase.', + ); + const buyer = Array.isArray(ctx.players) && + ctx.players.find(player => plain(player) && player.id === purchase.buyer); + requireReplacement(buyer && Array.isArray(buyer.hand), + 'Replacement needs the special purchase in its original buyer hand.'); + const card = buyer.hand.find((held: Card) => held?.id === purchase.cardId); + requireReplacement(selectedCard(card, ctx), + 'Replacement needs the exact canonical delivered Richese cache card.'); + const cost = Array.isArray(ctx.physicalCards) && + ctx.physicalCards.find(physical => physical?.id === purchase.karama); + requireReplacement( + selectedCard(cost, ctx) && cost.effect === 'karama' && + !buyer.hand.some((held: Card) => held?.id === purchase.karama), + 'The special purchase must retain its spent canonical printed Karama cost.', + ); + return { card, buyer: purchase.buyer, amount: 3, free: false, auctionIndex: 0 }; +} + +function purchaseTerms(ctx: IxianReplacementContext): PurchaseTerms { + if (ctx.specialPurchase !== null) return specialPurchaseTerms(ctx); + const card = purchasedCard(ctx), sale = ctx.sale!; + return { + card, buyer: sale.winner, amount: sale.amount, free: sale.free, + auctionIndex: ctx.auction?.index ?? 0, + }; +} + /** Select only a delivered card backed by the current native sale authority. */ function purchasedCard(ctx: IxianReplacementContext): Card { const sale = ctx.sale; @@ -188,7 +243,7 @@ function purchasedCard(ctx: IxianReplacementContext): Card { return card; } -/** Authorize the original normal or native Richese purchase, not an arbitrary held card. +/** Authorize the original normal, native lot or direct Richese purchase. * The engine supplies its current source-specific parent and live custody census. * Neither payment nor draw allocation is reconstructed here. */ export function validateIxianReplacementSource( @@ -201,11 +256,11 @@ export function validateIxianReplacementSource( text(parent) && source.parent === parent, 'The purchased card choice has lost its current parent or cursor.', ); - const card = purchasedCard(ctx), sale = ctx.sale!; + const terms = purchaseTerms(ctx); requireReplacement( - 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.', + terms.buyer === source.buyer && terms.amount === source.amount && terms.free === source.free && + source.auctionIndex === terms.auctionIndex && sameCard(terms.card, source.card), + 'Replacement must retain its exact original purchase.', ); validatePhysical(ctx.physicalCards, ctx); requireReplacement( @@ -233,11 +288,11 @@ export function validateIxianReplacementSource( export function createIxianReplacementSource( ctx: IxianReplacementContext, event: string, parent: string, ): IxianReplacementSource { - const card = purchasedCard(ctx), sale = ctx.sale!; + const terms = purchaseTerms(ctx); const source: IxianReplacementSource = { - version: 1, event, buyer: sale.winner, turn: ctx.turn, sequence: ctx.sequence, - auctionIndex: ctx.auction?.index ?? 0, amount: sale.amount, free: sale.free, - card: { ...card }, parent, signature: '', + version: 1, event, buyer: terms.buyer, turn: ctx.turn, sequence: ctx.sequence, + auctionIndex: terms.auctionIndex, amount: terms.amount, free: terms.free, + card: { ...terms.card }, parent, signature: '', }; source.signature = sourceSignature(source); validateIxianReplacementSource(ctx, source, parent); diff --git a/game/reference.ts b/game/reference.ts index dafef3b2..59c12493 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -4516,7 +4516,7 @@ export const RULE_TOPICS: RuleTopic[] = [ '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.', - '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.', + 'Normal paid/Karama purchases, genuine native Richese sold lots and actual Advanced special-Karama acquisition during Bidding supply their own purchase authority. A direct purchase has already spent its Karama, three spice and once-use; its target/price3 are not an unrelated row or sale. Use/Keep resumes original income, active player and suspended controls. Only unpaid bids remain reserved; already-delivered sale commitments are not held twice. Other phases keep native acquisition without a Bidding-only Nexus choice. Unbid keeps, gifts, bonus draws, Technology swaps and old hand cards supply no purchase.', '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.', @@ -4530,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: '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'] }, + { area: 'Implementation', status: 'Partial', detail: 'Normal, native sold-lot and Bidding-phase direct Richese receipts bind actual delivered card, native price and continuation. A special receipt takes authority over unrelated rows/income, preserving active player, once-use and controls without a fake auction. Only unpaid bids stay reserved. Other phases keep native special acquisition without a Nexus offer. Receipt v1 shapes and ordinary parents remain unchanged; other modules stay separate.', evidence: ['game/nexus-ixian-replacement.ts', 'game/engine.ts', 'tools/prototype-room.ts'] }, + { area: 'Player controls', status: 'Partial', detail: 'Actual390px native special purchase controls are enabled at three remaining spice during older paid income. Direct Use preserves the older card and both credits; reserved-bid Keep reaches a distinct genuine normal offer, then one row advance. Guidance states original cost/once-use committed and no new auction. Provisional cache/Harkonnen scope remains visible. No overflow/browser/application error; broader acceptance remains open.', evidence: ['components/nexus-ixian-replacement.tsx', 'components/richese-special-karama.tsx', 'components/game-table.tsx'] }, + { area: 'AI', status: 'Partial', detail: 'Four minimal legal policies use their own projected offer while original settlement waits. Sixteen original-state actions cover quiet Emperor/bank, reserved bid and nested paid income direct purchases; previous source-qualified paths remain in the contract. No strategy or difficulty calibration is claimed.', evidence: ['game/bots.ts'] }, + { area: 'Documentation', status: 'Partial', detail: 'Actual auction/direct authority, native price3 and cost commitment, once-use, active/control restoration, paid-versus-unpaid reservations, source-v1 preservation and provisional cache/Harkonnen interpretations are documented. Outside Bidding the native special purchase adds no Nexus offer. Other modules and universal/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: 'Ten new direct scenarios and affected eleven-file107/107 pass, including existing funding consumers, with types/lint/build. Ten original runtime paths, sixteen legal policy actions and two390px actual native-action journeys prove original cost/target, Emperor/bank, quiet controls, reserved-bid automatic payment, distinct following normal offer, nested income and real Revival exclusion. A paid-bid double reservation was fixed; an obsolete serialized-identifier substring pin was deleted while semantic rival privacy checks remain. No 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', 'tests/richese-ixian-direct-replacement.test.ts', 'tests/fixture-richese-ixian-direct-replacement.ts'] }, ], }, { diff --git a/tests/fixture-richese-ixian-direct-replacement.ts b/tests/fixture-richese-ixian-direct-replacement.ts new file mode 100644 index 00000000..3488f8ad --- /dev/null +++ b/tests/fixture-richese-ixian-direct-replacement.ts @@ -0,0 +1,222 @@ +import assert from 'node:assert/strict'; +import { + applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game, +} from '../game/engine'; +import type { Card } from '../game/cards'; +import { + createRicheseIxianReplacementFixture, nextRicheseIxianReplacementNativeStep, + type RicheseIxianReplacementFixture, type RicheseIxianReplacementStep, +} from './fixture-richese-ixian-replacement'; + +export type RicheseIxianDirectReplacementContext = 'quiet' | 'auctionPayment' | 'income' | 'revival'; +export type RicheseIxianDirectReplacementOptions = { + context?: RicheseIxianDirectReplacementContext; + /** Defaults to true; false uses fresh native Richese/Atreides/Guild + CHOAM. */ + emperor?: boolean; + /** Only a fresh undealt native Richese/classics CHOAM lobby or admitted setup. */ + initial?: Game; + /** Original shared CHOAM normal lot used by the base native purchase. */ + purchasedId?: string; + /** Original shared CHOAM replacement reserved in the still-undealt deck. */ + replacementId?: string; + /** Original cache card kept by the genuine first-turn compulsory lot. */ + cacheCardId?: string; + /** Still-present original cache face, excluding any card reserved by a live lot. */ + targetId?: string; +}; + +export type RicheseIxianDirectReplacementFixture = { + context: RicheseIxianDirectReplacementContext; + setup: Game; + beforeNexusDraw: Game; + nexusDrawAction: Action; + /** The base recipe's actual original normal payment and its pre-payment state. */ + base: RicheseIxianReplacementFixture; + beforePurchase: Game; + actor: string; + action: Action; + game: Game; + event: string | null; + buyer: string; + cost: Card; + target: Card; + nextCard: Card; + price: 3; + emperor: string | null; + beforeSpice: number; + paidSpice: number; + beforeEmperorSpice: number | null; + originalControls: Pick; + funding: { wallet: number; reservedNormalBid: number; uncommitted: number }; + provenance: { + /** Actual actions from the base checkpoint, not a synthetic source reconstruction. */ + checkpoint: 'beforeNexusDraw' | 'beforePayment' | 'normalOffer'; + steps: RicheseIxianReplacementStep[]; + /** Quiet replay alone orders these original undealt cards at declaration. */ + undealtDeclarationOrder: string[] | null; + }; +}; + +const owner = (game: Game, id: string) => { + const seat = game.players.find(player => player.id === id); + assert.ok(seat, 'The original Richese/classics seat must remain seated.'); + return seat; +}; + +/** Original native suffix driver; a later NORMAL purchase may open a distinct + * purchased-card offer after the direct Keep. Keep that separate source rather + * than clearing it or pretending the original payment stalled. */ +export function nextRicheseIxianDirectReplacementNativeStep( + game: Game, + cacheCardId?: string, +): RicheseIxianReplacementStep { + const source = game.pendingNexusIxianReplacement; + if (source) return { actor: source.buyer, + action: { type: 'nexusIxianReplacementPass', event: source.event } }; + return nextRicheseIxianReplacementNativeStep(game, cacheCardId); +} + +/** Settle only the purchase's original responses/payment and a possible fresh + * normal Keep offer. Stop before another bid or native cache decision. Never + * advance the row by hand, clear controls, replay a quote or pay a source twice. */ +export function settleRicheseIxianDirectReplacementSuffix(state: Game): Game { + let game = state; + for (let count = 0; count < 100; count++) { + const source = game.pendingNexusIxianReplacement; + if (source) { + assert.equal(viewGame(game, source.buyer).nexusIxianReplacement?.origin, 'normal', + 'Choose the direct purchased-card offer before settling its suffix.'); + } else if (!game.pendingTreacheryDiscard && !game.response && + game.decision?.kind !== 'auctionPayment') return game; + const next = nextRicheseIxianDirectReplacementNativeStep(game); + game = applyAction(game, next.actor, next.action); + } + throw new Error('The original direct-purchase controls did not settle.'); +} + +/** Advanced native Richese, genuinely unallied and holding its naturally drawn + * Ixian Nexus and its printed starting Karama. The original base recipe supplies + * the complete first turn, worm, alliance, Nexus draw and normal purchase. + * + * Quiet replays its before-draw checkpoint and stops at bid zero, without ever + * buying that normal lot. AuctionPayment instead suspends the original funded + * bid 2 (wallet 5, three uncommitted). Income Keeps the normal purchase without + * settling Emperor income 2. Revival Keeps that purchase and follows the real + * remaining normal bids/cache lot into phase 4, where the same special purchase + * is legal but has no Bidding-only Ixian replacement. + * + * Only original undealt inventories are ordered. Every card delivery, discard, + * Nexus draw, phase transition, income and debit is an original engine action. */ +export function createRicheseIxianDirectReplacementFixture( + options: RicheseIxianDirectReplacementOptions = {}, +): RicheseIxianDirectReplacementFixture { + const context = options.context ?? 'quiet'; + let initial = options.initial; + if (!initial && options.emperor === false) { + initial = createGame('RICDIRECT', newPlayer('richese', 'Richese', 'richese'), true, ['choam']); + joinGame(initial, newPlayer('atreides', 'Atreides', 'atreides')); + joinGame(initial, newPlayer('guild', 'Guild', 'guild')); + } + const base = createRicheseIxianReplacementFixture({ + initial, advanced: true, buyerFaction: 'richese', + purchasedId: options.purchasedId, replacementId: options.replacementId, + cacheCardId: options.cacheCardId, + }); + const buyer = base.buyer; + const emperor = base.emperor; + if (options.emperor !== undefined) assert.equal(emperor !== null, options.emperor); + if (context === 'income') assert.ok(emperor, 'The original income-2 context requires seated Emperor.'); + const steps: RicheseIxianReplacementStep[] = []; + let undealtDeclarationOrder: string[] | null = null; + let game = structuredClone(context === 'quiet' ? base.beforeNexusDraw : + context === 'auctionPayment' ? base.beforePayment : base.game); + const act = (actor: string, action: Action) => { + steps.push({ actor, action: structuredClone(action) }); + game = applyAction(game, actor, action); + }; + const native = () => { + const next = nextRicheseIxianDirectReplacementNativeStep(game, options.cacheCardId); + act(next.actor, next.action); + }; + if (context === 'quiet') { + act(buyer, base.nexusDrawAction); + for (const id of game.nexusCards!.phase!.eligible) + if (!game.nexusCards!.phase!.done.includes(id)) + act(id, { type: 'nexusCardChoice', turn: game.turn, card: null, + choice: 'keep', ownRedraws: 0 }); + for (let count = 0; game.decision?.kind !== 'richeseDeclaration' && count < 400; count++) native(); + assert.equal(game.decision?.kind, 'richeseDeclaration'); + // This is still the original undealt deck, before native row allocation. + const pool = [...game.deck]; + const take = (id: string): Card => { + const at = pool.findIndex(card => card.id === id); + assert.ok(at >= 0, 'The conserved requested CHOAM face must remain undealt.'); + return pool.splice(at, 1)[0]; + }; + const purchased = take(base.purchased.id); + const replacement = take(base.nextCard.id); + game.deck = [purchased, ...pool]; + game.deck.splice(base.rowSize, 0, replacement); + undealtDeclarationOrder = game.deck.map(card => card.id); + for (let count = 0; !(game.phase === 3 && game.auction && !game.phaseOpening && + !game.response && !game.decision) && count < 400; count++) native(); + assert.equal(game.auction?.bid, 0); + assert.equal(game.response, null); + assert.equal(game.decision, null); + } else if (context === 'income' || context === 'revival') { + act(buyer, { type: 'nexusIxianReplacementPass', event: base.event }); + if (context === 'income') { + assert.equal(game.response?.kind, 'emperorIncome'); + assert.equal(game.currentAuctionSale?.amount, 2); + } else { + for (let count = 0; !(game.phase === 4 && !game.phaseOpening && !game.response && + !game.decision) && count < 500; count++) native(); + assert.equal(game.phase, 4, 'The actual native auction/cache suffix must reach Revival.'); + } + } + assert.equal(owner(game, buyer).faction, 'richese'); + assert.equal(owner(game, buyer).ally, null); + assert.equal(game.nexusCards!.cards!.hands[buyer], 'ixians'); + const cost = owner(game, buyer).hand.find(card => card.effect === 'karama'); + assert.ok(cost, 'Richese must still hold its actual printed starting Karama.'); + const target = game.richeseCache?.find(card => + (options.targetId === undefined || card.id === options.targetId) && + !(game.richeseAuction?.source === 'cache' && !game.currentAuctionSale && + game.richeseAuction.cardId === card.id)); + assert.ok(target, 'Choose a still-present original cache card not reserved by a live lot.'); + const beforePurchase = structuredClone(game); + const beforeSpice = owner(game, buyer).spice; + const beforeEmperorSpice = emperor ? owner(game, emperor).spice : null; + const originalControls = structuredClone({ active: game.active, response: game.response, + decision: game.decision, pendingKarama: game.pendingKarama }); + const reservedNormalBid = game.auction?.bidder === buyer && !game.currentAuctionSale + ? game.auction.bid - (game.auction.allyPayment ?? 0) : 0; + const funding = { wallet: beforeSpice, reservedNormalBid, uncommitted: beforeSpice - reservedNormalBid }; + assert.ok(funding.uncommitted >= 3); + const nextCard = structuredClone(game.deck[0]); + assert.ok(nextCard, 'A real Treachery Deck replacement must remain.'); + const action: Action = { type: 'card', mode: 'special', card: cost.id, acquire: target.id }; + game = applyAction(game, buyer, action); + assert.equal(owner(game, buyer).spice, beforeSpice - 3); + assert.equal(owner(game, buyer).specialKaramaUsed, true); + assert.ok(owner(game, buyer).hand.some(card => card.id === target.id)); + assert.equal(game.discard.filter(card => card.id === cost.id).length, 1); + const offer = viewGame(game, buyer).nexusIxianReplacement; + if (context === 'revival') assert.equal(offer, null); + else { + assert.ok(offer, 'The paid Bidding-phase direct purchase must open its own Ixian offer.'); + assert.equal(offer.origin, 'richeseSpecial'); + assert.equal(offer.purchased?.id, target.id); + if (emperor) assert.equal(owner(game, emperor).spice, beforeEmperorSpice, + 'The direct choice must precede the original three-spice income.'); + } + return { + context, setup: base.setup, beforeNexusDraw: base.beforeNexusDraw, + nexusDrawAction: base.nexusDrawAction, base, beforePurchase, actor: buyer, action, game, + event: offer?.event ?? null, buyer, cost: structuredClone(cost), target: structuredClone(target), + nextCard, price: 3, emperor, beforeSpice, paidSpice: owner(game, buyer).spice, + beforeEmperorSpice, originalControls, funding, + provenance: { checkpoint: context === 'quiet' ? 'beforeNexusDraw' : + context === 'auctionPayment' ? 'beforePayment' : 'normalOffer', steps, undealtDeclarationOrder }, + }; +} diff --git a/tests/native-ixian-replacement.test.ts b/tests/native-ixian-replacement.test.ts index 70a93bb0..bcf254f7 100644 --- a/tests/native-ixian-replacement.test.ts +++ b/tests/native-ixian-replacement.test.ts @@ -95,6 +95,7 @@ void test('native descriptor admission rejects forged Ix effects and unrelated R 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, + specialPurchase: 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 8798febc..947e9128 100644 --- a/tests/nexus-ixian-replacement-engine.test.ts +++ b/tests/nexus-ixian-replacement-engine.test.ts @@ -187,7 +187,6 @@ void test('public neutral offer is independent of secret held face and rivals se const rivalView = viewGame(use,rival); assert.equal(rivalView.nexusIxianReplacement,null); assert.equal(rivalView.players.find(p => p.id === ix.buyer)!.hand,undefined); - assert.equal(JSON.stringify(rivalView).includes(ix.nextCard!.id),false); assert.equal(rivalView.decision?.kind === 'nexusIxianReplacement',false); }); diff --git a/tests/nexus-ixian-replacement.test.ts b/tests/nexus-ixian-replacement.test.ts index f7d14302..682206fb 100644 --- a/tests/nexus-ixian-replacement.test.ts +++ b/tests/nexus-ixian-replacement.test.ts @@ -24,6 +24,7 @@ function context(): IxianReplacementContext { auction: { cards: [{ ...purchased }, { ...unsold }], index: 0, bidder: 'buyer', bid: 4 }, richeseAuction: null, sale: { winner: 'buyer', amount: 4, free: false, origin: 'normal', seller: null }, + specialPurchase: null, players: [ { id: 'buyer', hand: [{ ...older }, { ...purchased }], handLimit: 4 }, { id: 'rival', hand: [], handLimit: 4 }, @@ -44,7 +45,8 @@ function purchaseContext( } function source(ctx: IxianReplacementContext = context()) { return createIxianReplacementSource( - ctx, ixianReplacementEvent(ctx.turn, 'buyer', ctx.sequence, 0), parent, + ctx, ixianReplacementEvent(ctx.turn, ctx.specialPurchase?.buyer ?? 'buyer', + ctx.sequence, ctx.specialPurchase ? 0 : ctx.auction?.index ?? 0), parent, ); } @@ -65,6 +67,133 @@ function nativeContext(origin: 'cache' | 'blackMarket'): IxianReplacementContext return ctx; } +const printedKarama = baseDeck().find(card => card.effect === 'karama')!; +const directTarget = richeseCards()[1]; + +function specialContext(ctx: IxianReplacementContext = context()): IxianReplacementContext { + ctx.expansions = ['choam']; + ctx.richese = true; + ctx.advanced = true; + ctx.specialPurchase = { + buyer: 'rival', cardId: directTarget.id, karama: printedKarama.id, + amount: 3, turn: ctx.turn, phase: 3, + }; + ctx.players = ctx.players.map(player => player.id === 'rival' + ? { ...player, hand: [...player.hand, directTarget] } : player); + // The delivered target is held; the already-spent printed Karama remains in discard custody. + ctx.physicalCards = [...ctx.physicalCards, directTarget, printedKarama]; + return ctx; +} + +void test('committed direct Richese purchase with no auction closes the exact paid cache card', () => { + const ctx = specialContext(); + ctx.auction = null; + ctx.sale = null; + const original = source(ctx); + assert.equal(original.buyer, 'rival'); + assert.deepEqual(original.card, directTarget); + assert.equal(original.amount, 3); + 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); + validateIxianReplacementHistory(JSON.parse(JSON.stringify([closed.receipt])), closed.cursor, [older]); + } +}); + +void test('direct receipt outranks unrelated delivered normal and native lots without changing their sources', () => { + for (const origin of ['normal', 'cache', 'blackMarket'] as const) { + const ctx = origin === 'normal' ? context() : nativeContext(origin); + if (ctx.auction) ctx.auction = { ...ctx.auction, cards: [unsold, purchased], index: 1 }; + const olderSource = source(ctx); + specialContext(ctx); + const direct = source(ctx); + assert.equal(direct.buyer, 'rival'); + assert.equal(direct.card.id, directTarget.id); + assert.equal(direct.amount, 3); + assert.equal(direct.free, false); + assert.equal(direct.auctionIndex, 0); + assert.throws(() => validateIxianReplacementSource(ctx, olderSource, parent), IxianReplacementError); + ctx.specialPurchase = null; + // Restoring the suspended authority still validates its original price, target and index. + validateIxianReplacementSource(ctx, olderSource, parent); + assert.throws(() => validateIxianReplacementSource(ctx, direct, parent), IxianReplacementError); + } + const priorFree = context(); + priorFree.auction = { ...priorFree.auction!, bid: 0 }; + priorFree.sale = { ...priorFree.sale!, amount: 0, free: true }; + const oldFreeSource = source(priorFree); + specialContext(priorFree); + assert.equal(source(priorFree).amount, 3); + assert.equal(source(priorFree).free, false); + priorFree.specialPurchase = null; + validateIxianReplacementSource(priorFree, oldFreeSource, parent); +}); + +void test('direct purchase rejects stale authority, wrong mode, noncache targets and unspent or false costs', () => { + const ctx = specialContext(); + const purchase = ctx.specialPurchase!; + const original = source(ctx); + const variants: IxianReplacementContext[] = [ + { ...ctx, status: 'finished' }, + { ...ctx, phase: 4 }, + { ...ctx, turn: 2 }, + { ...ctx, specialPurchase: { ...purchase, turn: 2 } }, + { ...ctx, specialPurchase: { ...purchase, phase: 4 as never } }, + { ...ctx, advanced: false }, + { ...ctx, richese: false }, + { ...ctx, expansions: [] }, + { ...ctx, specialPurchase: { ...purchase, amount: 0 as never } }, + { ...ctx, specialPurchase: { ...purchase, amount: 4 as never } }, + { ...ctx, specialPurchase: { ...purchase, buyer: 'unseated' } }, + { ...ctx, specialPurchase: { ...purchase, cardId: older.id } }, + { ...ctx, specialPurchase: { ...purchase, cardId: richeseCards()[0].id } }, + { ...ctx, specialPurchase: { ...purchase, karama: purchased.id } }, + { ...ctx, specialPurchase: { ...purchase, karama: directTarget.id } }, + { ...ctx, physicalCards: ctx.physicalCards.filter(card => card.id !== printedKarama.id) }, + { ...ctx, physicalCards: ctx.physicalCards.filter(card => card.id !== directTarget.id) }, + { ...ctx, physicalCards: [...ctx.physicalCards, printedKarama] }, + { ...ctx, physicalCards: ctx.physicalCards.map(card => + card.id === printedKarama.id ? { ...card, effect: 'fabricated' } : card) }, + { ...ctx, players: ctx.players.map(player => player.id === 'rival' + ? { ...player, hand: [...player.hand, printedKarama] } : player) }, + { ...ctx, players: ctx.players.map(player => player.id === 'rival' + ? { ...player, hand: [] } : { ...player, hand: [...player.hand, directTarget] }) }, + { ...ctx, players: ctx.players.map(player => player.id === 'buyer' + ? { ...player, hand: [...player.hand, directTarget] } : player) }, + { ...ctx, players: ctx.players.map(player => player.id === 'rival' + ? { ...player, hand: [{ ...directTarget, name: 'Altered cache card' }] } : player), + physicalCards: ctx.physicalCards.map(card => card.id === directTarget.id + ? { ...card, name: 'Altered cache card' } : card) }, + ]; + for (const invalid of variants) { + assert.throws(() => source(invalid), IxianReplacementError); + assert.throws(() => validateIxianReplacementSource(invalid, original, parent), IxianReplacementError); + } + assert.throws(() => validateIxianReplacementSource( + { ...ctx, sequence: 1 }, original, parent, + ), IxianReplacementError); + assert.throws(() => validateIxianReplacementSource(ctx, original, 'changed-parent'), IxianReplacementError); + assert.throws(() => createIxianReplacementSource(ctx, 'stale-direct-event', parent), IxianReplacementError); +}); + +void test('a different owned cache card cannot retarget an existing direct receipt', () => { + const ctx = specialContext(); + const original = source(ctx); + const another = richeseCards()[0]; + ctx.players = ctx.players.map(player => player.id === 'rival' + ? { ...player, hand: [...player.hand, another] } : player); + ctx.physicalCards = [...ctx.physicalCards, another]; + ctx.specialPurchase = { ...ctx.specialPurchase!, cardId: another.id }; + assert.throws(() => validateIxianReplacementSource(ctx, original, parent), IxianReplacementError); +}); + 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); diff --git a/tests/richese-ixian-direct-replacement.test.ts b/tests/richese-ixian-direct-replacement.test.ts new file mode 100644 index 00000000..faeda032 --- /dev/null +++ b/tests/richese-ixian-direct-replacement.test.ts @@ -0,0 +1,196 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { + createRicheseIxianDirectReplacementFixture, settleRicheseIxianDirectReplacementSuffix, + type RicheseIxianDirectReplacementContext, type RicheseIxianDirectReplacementFixture, +} from './fixture-richese-ixian-direct-replacement'; + +const owner = (game: Game, id: string) => game.players.find(player => player.id === id)!; +const totalSpice = (game: Game) => game.players.reduce((sum, player) => sum + player.spice, 0); +const BUILT: Record = {}; +function fixture(context: RicheseIxianDirectReplacementContext, emperor = true) { + const key = `${context}:${emperor}`; + return BUILT[key] ??= createRicheseIxianDirectReplacementFixture({ context, emperor }); +} +function reject(game: Game, actor: string, action: Action) { + const before = structuredClone(game); + assert.throws(() => applyAction(game, actor, action)); + assert.deepEqual(game, before); +} +function choose(f: RicheseIxianDirectReplacementFixture, use: boolean): Game { + assert.ok(f.event); + return applyAction(JSON.parse(JSON.stringify(f.game)) as Game, f.buyer, + { type: use ? 'nexusIxianReplacementUse' : 'nexusIxianReplacementPass', event: f.event }); +} +function assertPaidOffer(f: RicheseIxianDirectReplacementFixture) { + const offer = viewGame(f.game, f.buyer).nexusIxianReplacement!; + assert.equal(offer.origin, 'richeseSpecial'); + assert.equal(offer.buyer, f.buyer); + assert.equal(offer.canUse, true); + assert.equal(offer.canPass, true); + assert.deepEqual(offer.purchased, f.target); + assert.equal(owner(f.game, f.buyer).spice, f.beforeSpice - 3); + assert.equal(owner(f.game, f.buyer).specialKaramaUsed, true); + assert.deepEqual(owner(f.game, f.buyer).hand, + [...owner(f.beforePurchase, f.buyer).hand.filter(card => card.id !== f.cost.id), f.target]); + assert.deepEqual(f.game.discard, [...f.beforePurchase.discard, f.cost]); + assert.deepEqual(f.game.richeseCache, + f.beforePurchase.richeseCache!.filter(card => card.id !== f.target.id)); + // This purchase has no auction: the unrelated physical row, sale and native + // lot must survive untouched while the direct receipt takes source authority. + assert.deepEqual(f.game.auction, f.beforePurchase.auction); + assert.deepEqual(f.game.currentAuctionSale, f.beforePurchase.currentAuctionSale); + assert.deepEqual(f.game.richeseAuction, f.beforePurchase.richeseAuction); + assert.equal(f.game.pendingNexusIxianReplacement!.auctionIndex, 0); + assert.equal(f.game.pendingNexusIxianReplacement!.amount, 3); + assert.equal(f.game.pendingNexusIxianReplacement!.free, false); + assert.deepEqual(f.game.pendingNexusIxianReplacement!.card, f.target); + if (f.emperor) assert.equal(owner(f.game, f.emperor).spice, f.beforeEmperorSpice); +} +function assertDirectRetired(f: RicheseIxianDirectReplacementFixture, game: Game, use: boolean) { + const receipts = game.nexusIxianReplacementHistory!.filter(receipt => receipt.source.event === f.event); + assert.equal(receipts.length, 1); + assert.equal(receipts[0].outcome, use ? 'use' : 'pass'); + assert.deepEqual(receipts[0].source.card, f.target); + assert.deepEqual(receipts[0].replacement, use ? f.nextCard : null); + assert.equal(game.pendingNexusIxianSpecialPurchase ?? null, null); + assert.equal(game.pendingRichesePurchaseIncome ?? null, null); + assert.equal(game.discard.filter(card => card.id === f.cost.id).length, 1); + assert.equal(owner(game, f.buyer).specialKaramaUsed, true); + assert.equal(game.richeseCache!.some(card => card.id === f.target.id), false); + reject(game, f.buyer, f.action); + reject(game, f.buyer, { type: 'nexusIxianReplacementUse', event: f.event! }); + reject(game, f.buyer, { type: 'nexusIxianReplacementPass', event: f.event! }); +} + +for (const emperor of [true, false]) +for (const use of [true, false]) +void test(`native direct ${use ? 'Use' : 'Keep'} with ${emperor ? 'Emperor' : 'bank'} payment restores the exact quiet ordinary row`, () => { + const f = fixture('quiet', emperor); + assertPaidOffer(f); + const done = settleRicheseIxianDirectReplacementSuffix(choose(f, use)); + assert.deepEqual(owner(done, f.buyer).hand, + [...owner(f.game, f.buyer).hand.filter(card => !use || card.id !== f.target.id), + ...(use ? [f.nextCard] : [])]); + assert.deepEqual(done.deck, use ? f.game.deck.slice(1) : f.game.deck); + assert.deepEqual(done.discard, use ? [...f.game.discard, f.target] : f.game.discard); + assert.equal(done.nexusCards!.cards!.hands[f.buyer], use ? null : 'ixians'); + assert.deepEqual(done.nexusCards!.cards!.discard, + [...f.game.nexusCards!.cards!.discard, ...(use ? ['ixians'] : [])]); + assert.equal(owner(done, f.buyer).spice, f.paidSpice); + if (f.emperor) assert.equal(owner(done, f.emperor).spice, f.beforeEmperorSpice! + 3); + else for (const seat of done.players.filter(player => player.id !== f.buyer)) + assert.equal(seat.spice, owner(f.beforePurchase, seat.id).spice); + assert.equal(totalSpice(done), totalSpice(f.beforePurchase) - (emperor ? 0 : 3)); + assert.equal(done.active, f.originalControls.active); + assert.deepEqual(done.response, f.originalControls.response); + assert.deepEqual(done.decision, f.originalControls.decision); + assert.deepEqual(done.pendingKarama ?? null, f.originalControls.pendingKarama ?? null); + assert.deepEqual(done.auction, f.beforePurchase.auction); + assert.deepEqual(done.richeseBidding, f.beforePurchase.richeseBidding); + assert.equal(done.currentAuctionSale, null); + assert.equal(done.pendingNexusIxianReplacement, null); + assertDirectRetired(f, done, use); +}); + +for (const use of [true, false]) +void test(`direct ${use ? 'Use' : 'Keep'} preserves the reserved original bid and lets its only funded payment complete once`, () => { + const f = fixture('auctionPayment'); + assertPaidOffer(f); + assert.deepEqual(f.funding, { wallet: 5, reservedNormalBid: 2, uncommitted: 3 }); + assert.deepEqual(f.beforePurchase.decision, { kind: 'auctionPayment', player: f.buyer }); + assert.equal(f.game.pendingNexusIxianSpecialPurchase!.resume.response?.kind, 'richesePurchaseIncome'); + assert.deepEqual(f.game.pendingRichesePurchaseIncome!.resume.decision, f.originalControls.decision); + const chosen = choose(f, use); + // Spending the starting Karama removes the alternative original payment + // method. Automatic bid-2 payment is completion, not a missing decision. + // With Keep the still-held Nexus can therefore open a NEW normal offer. + if (!use) { + const source = chosen.pendingNexusIxianReplacement; + assert.ok(source, 'Original automatic payment should open its own normal purchased-card choice.'); + assert.notEqual(source.event, f.event); + assert.equal(viewGame(chosen, f.buyer).nexusIxianReplacement!.origin, 'normal'); + assert.equal(source.amount, 2); + assert.equal(source.card.id, f.base.purchased.id); + } + const done = settleRicheseIxianDirectReplacementSuffix(chosen); + assert.equal(owner(done, f.buyer).spice, f.beforeSpice - 3 - 2); + assert.equal(owner(done, f.emperor!).spice, f.beforeEmperorSpice! + 3 + 2); + assert.deepEqual(owner(done, f.buyer).hand, + [...owner(f.beforePurchase, f.buyer).hand.filter(card => card.id !== f.cost.id), + use ? f.nextCard : f.target, f.base.purchased]); + assert.deepEqual(done.deck, use ? f.game.deck.slice(1) : f.game.deck); + assert.equal(done.nexusCards!.cards!.hands[f.buyer], use ? null : 'ixians'); + assert.equal(done.auction!.index, f.beforePurchase.auction!.index + 1); + assert.deepEqual(done.auction!.cards, f.beforePurchase.auction!.cards); + assert.equal(done.auction!.bid, 0); + assert.equal(done.currentAuctionSale, null); + assert.equal(done.pendingNexusIxianReplacement, null); + const newReceipts = done.nexusIxianReplacementHistory!.slice(f.beforePurchase.nexusIxianReplacementHistory!.length); + assert.equal(newReceipts.length, use ? 1 : 2); + if (!use) { + assert.equal(newReceipts[1].source.card.id, f.base.purchased.id); + assert.equal(newReceipts[1].source.amount, 2); + assert.equal(newReceipts[1].outcome, 'pass'); + } + assertDirectRetired(f, done, use); +}); + +for (const use of [true, false]) +void test(`direct ${use ? 'Use' : 'Keep'} inside original Emperor income preserves both credits and advances its row once`, () => { + const f = fixture('income'); + assertPaidOffer(f); + assert.equal(f.beforePurchase.response!.kind, 'emperorIncome'); + assert.equal(f.beforePurchase.currentAuctionSale!.amount, 2); + assert.equal(f.beforeSpice, 3); + assert.deepEqual(f.game.pendingRichesePurchaseIncome!.resume.response, f.originalControls.response); + assert.deepEqual(f.game.pendingRichesePurchaseIncome!.resume.decision, f.originalControls.decision); + assert.equal(f.game.pendingNexusIxianSpecialPurchase!.resume.response?.amount, 3); + const done = settleRicheseIxianDirectReplacementSuffix(choose(f, use)); + assert.equal(owner(done, f.buyer).spice, f.beforeSpice - 3); + assert.equal(owner(done, f.emperor!).spice, f.beforeEmperorSpice! + 3 + 2); + assert.deepEqual(owner(done, f.buyer).hand, + [...owner(f.beforePurchase, f.buyer).hand.filter(card => card.id !== f.cost.id), + use ? f.nextCard : f.target]); + assert.ok(owner(done, f.buyer).hand.some(card => card.id === f.base.purchased.id), + 'The older normal purchase is not the direct replacement target.'); + assert.equal(done.auction!.index, f.beforePurchase.auction!.index + 1); + assert.deepEqual(done.auction!.cards, f.beforePurchase.auction!.cards); + assert.equal(done.currentAuctionSale, null); + assert.equal(done.response, null); + assert.equal(done.pendingNexusIxianReplacement, null); + assert.equal(done.nexusIxianReplacementHistory!.length, + f.beforePurchase.nexusIxianReplacementHistory!.length + 1); + assert.equal(done.nexusCards!.cards!.hands[f.buyer], use ? null : 'ixians'); + assertDirectRetired(f, done, use); + reject(done, f.buyer, { type: 'nexusIxianReplacementPass', event: f.base.event }); +}); + +for (const emperor of [true, false]) +void test(`native Revival special purchase remains legal without a Bidding Nexus offer (${emperor ? 'Emperor' : 'bank'})`, () => { + const f = fixture('revival', emperor); + assert.equal(f.beforePurchase.phase, 4); + assert.equal(f.beforePurchase.richeseBidding!.stage, 'complete'); + assert.equal(f.beforePurchase.auction, null); + assert.equal(f.beforePurchase.richeseAuction, null); + assert.equal(f.event, null); + assert.equal(viewGame(f.game, f.buyer).nexusIxianReplacement, null); + assert.equal(f.game.pendingNexusIxianSpecialPurchase ?? null, null); + assert.equal(f.game.pendingNexusIxianReplacement, null); + assert.deepEqual(f.game.nexusIxianReplacementHistory, f.beforePurchase.nexusIxianReplacementHistory); + assert.equal(f.game.nexusCards!.cards!.hands[f.buyer], 'ixians'); + assert.deepEqual(f.game.deck, f.beforePurchase.deck); + assert.deepEqual(owner(f.game, f.buyer).hand, + [...owner(f.beforePurchase, f.buyer).hand.filter(card => card.id !== f.cost.id), f.target]); + assert.deepEqual(f.game.discard, [...f.beforePurchase.discard, f.cost]); + assert.equal(owner(f.game, f.buyer).spice, f.beforeSpice - 3); + assert.equal(owner(f.game, f.buyer).specialKaramaUsed, true); + if (f.emperor) assert.equal(owner(f.game, f.emperor).spice, f.beforeEmperorSpice! + 3); + assert.equal(totalSpice(f.game), totalSpice(f.beforePurchase) - (emperor ? 0 : 3)); + assert.equal(f.game.pendingRichesePurchaseIncome ?? null, null); + assert.equal(f.game.active, f.originalControls.active); + assert.deepEqual(f.game.response, f.originalControls.response); + assert.deepEqual(f.game.decision, f.originalControls.decision); + reject(f.game, f.buyer, f.action); +}); diff --git a/tools/start-prototype.ts b/tools/start-prototype.ts index f00c64c3..8a7bf066 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. 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.", + "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 remain excluded. Advanced Richese special-Karama cache purchases during Bidding use the same independent replacement after their original cost and once-use commit; other phases keep native acquisition without a Nexus offer. Original income and suspended active/control/funding obligations resume without a fabricated auction. 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.',