diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 7de07bbc..46e9a777 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -34,17 +34,20 @@ Normal purchased-card replacement now composes: - successful native BG Karama conversion, after its original counter; - Harkonnen's original cancelable bonus and eight-card limit under a visibly provisional replacement → income → bonus order matching the native ally path; -- original Tleilaxu/Ix47 setup and native purchases in Basic **and Advanced**. +- original Tleilaxu/Ix47 setup and native purchases in Basic **and Advanced**; +- native Richese/classics normal purchases with CHOAM35 and separate cache, + using selected-family card validation without enabling special-origin sales. -Latest native cases7/7 and affected six-file47/47 pass; four original Advanced -runtime paths, eight legal policy actions and actual CLI help pass. Earlier -BG/Harkonnen rule and mobile-control evidence remains separately source-bound -in the [canonical contract](NEXUS_IXIAN_REPLACEMENT_RULES.md), including the -selected provisional policy and explicit controlled fixtures. All five -facets remain Partial. Special origins/other modules and final assurance -stay separate. No saved room, human server, D1, deployment or public gate -changed. The latest admission slice adds no UI. -Final types/lint pass; current source anchor and19 topic paths resolve. +Latest affected seven-file65/65, eight original Richese runtime paths, +sixteen legal policy actions and two390px original-source journeys pass. +They preserve real payment/income, unchanged cache and one next-lot advance; +real final-lot/cache continuation retains the cache-origin gate. Types/lint +and actual CLI help pass. Two tool-only stale-node races were recovered +without replay; no application error or overflow. Earlier evidence and +explicit policies remain in the [canonical contract](NEXUS_IXIAN_REPLACEMENT_RULES.md). +All five facets stay Partial. No saved room, human server, D1, deployment or +public gate changed; full modules and final assurance remain separate. +Current source anchors and21 topic evidence paths resolve. **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 78696236..70af601e 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,27 @@ # Dune implementation status +## 16 October 2026 — Native Richese normal-purchase Ixian replacement + +Admitted fresh Richese/classics, exact CHOAM35 plus original cache, both modes +and Nexus alone. Precomputed selected-family validation admits shared original +CHOAM identities and native cache custody, but rejects unrelated families and +altered faces. Live context now receives selected expansions/native Richese; +source/receipt shapes remain unchanged, with Richese's original round/cache +bound only in its new native parent. Cache/Black Market/special origins remain +gated; the same holder's real cache purchase proves the distinction. + +Six catalogue and twelve native cases pass in affected65/65. Eight original +runtime paths, sixteen legal policy actions and two original390px journeys +preserve paid Poison Tooth/Artillery outcomes, native income, unchanged cache +and next/final-lot suffix. The fixture orders undealt cards before automatic +row allocation; no native response is forced open. Final types/lint and CLI +help pass after two fixture-only typing fixes. Two tool-only stale-node races +were recovered without action replay. No human server, D1, saved room, +deployment, public gate, new build, broad suite or HTTP assurance changed/run. +[Canonical source and scoped proof](NEXUS_IXIAN_REPLACEMENT_RULES.md#native-richese-normal-purchases--16-october-2026). +Seven current source anchors and21 topic evidence paths resolve; all five +checklist facets remain Partial without full-family/deployed certification. + ## 16 October 2026 — Advanced native Tleilaxu Ixian replacement Removed the Basic-only producer fence from original native Ix47/Tleilaxu diff --git a/docs/NEXUS_CARD_RULES.md b/docs/NEXUS_CARD_RULES.md index 3323b266..da1d72b7 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 genuine normal paid and Karama purchases in the original fresh classic or Tleilaxu profile in Basic/Advanced. Successful native BG conversion qualifies after its native counter. Harkonnen's explicitly provisional order is replacement, original income, original cancelable bonus; no extra bonus or eight-card slot is gained. Replace only the purchased physical card and preserve settlement/full-hand/same-card semantics. Cache/Black Market/special origins and combined modules remain guarded. Neutral timing and provisional ordering are product composition, not publisher policy. The adopted Advanced core PDF supplies no Nexus face or priority ruling. Full-module, recovery and deployed acceptance remain open; source-qualified rule/control proof is recorded in the linked contract. +The [bounded Ixian Secret Ally replacement integration](NEXUS_IXIAN_REPLACEMENT_RULES.md#bounded-local-runtime-contract) connects normal paid/Karama purchases in fresh classic, native Tleilaxu or native Richese profiles in Basic/Advanced. Richese retains exact CHOAM35 plus separate cache; selected catalogue membership does not enable cache/Black Market/special-origin replacement. Successful native BG conversion qualifies after its counter. Harkonnen's provisional order remains replacement, income, cancelable bonus, with no second bonus or eight-card slot. Original settlement/full-hand/same-card semantics remain. Neutral timing/order are product composition, not publisher policy; the adopted core PDF adds no Nexus face or priority ruling. Other modules and full/recovery/deployed acceptance remain open. **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_IXIAN_REPLACEMENT_RULES.md b/docs/NEXUS_IXIAN_REPLACEMENT_RULES.md index f96b67b9..bf8be031 100644 --- a/docs/NEXUS_IXIAN_REPLACEMENT_RULES.md +++ b/docs/NEXUS_IXIAN_REPLACEMENT_RULES.md @@ -2,6 +2,51 @@ 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. +## Native Richese normal purchases — 16 October 2026 + +The fresh private profile additionally admits native Richese plus classic +opponents in Basic/Advanced with exact CHOAM35, its separate original +ten-card cache and Nexus alone. Original cache auctions and qualifying +Nexus acquisition precede any normal purchased-card choice. Only a genuine +**normal-auction** purchase is connected; cache, Black Market and special +purchase replacement stay excluded under their separate source boundaries. + +Live card validation now receives selected `expansions` and native `richese` +presence. Exact base, Ix, CHOAM and cache membership is precomputed from the +original catalogues: shared Poison Tooth/Artillery identities qualify under +CHOAM, but other Ix-only cards do not. Cache custody requires both selected +CHOAM and actual Richese. A cache face later recycled into the normal deck +does not become a cache-origin sale merely because of its printed family. +Historical receipts retain their original shape and canonical identities. +The native source binds its original round/cache, not a replacement Game. + + +Six new selected-catalogue cases and twelve original Richese behavior cases +pass in the affected seven-file **65/65** union. Eight original-action +Use/Keep runtime paths cover both modes and native Richese/Atreides buyers; +sixteen legal policy actions pass. Actual final-normal-lot continuation +opens the original cache offer, and a subsequent real cache purchase by the +same retained Nexus holder still creates no replacement. + +Two styled390×844 original-source journeys pass: Basic Atreides replaces +Poison Tooth with actual deck-top Artillery (spice10→8, Emperor10→12); +Advanced Richese keeps Poison Tooth and Nexus (spice5→3, Emperor10→12). +The nine remaining cache cards stay untouched and each original lot advances +once. Width/scroll390; no application/browser error. Two tool-only stale-node +races were reported and recovered from authoritative state without replay. +The producer was repaired to order its still-undealt cards **before** the +declaration can automatically allocate its row; no native response is +artificially held open. Two fixture-only typing annotations preserve behavior. + +Final types/lint and actual CLI help pass. Only an owned memory-only loopback +host ran; no human server, D1, saved room, deployment or public start changed. +Proof is outside Git: `/tmp/dune-richese-ixian-runtime.json`, +`/tmp/dune-richese-ixian-ui-proof.json`. No new production build, broad suite, +HTTP or saved-game assurance was run; all five facets remain Partial. + +Seven current source anchors and21 topic evidence paths resolve with no +reference parse diagnostics; all five checklist facets remain Partial. + ## Advanced native Tleilaxu admission — 16 October 2026 The existing fresh Ix47/Tleilaxu-plus-classics profile now admits Advanced @@ -166,7 +211,7 @@ November's Karama table expressly permits stopping the native Ixian ally from di ## Bounded local runtime contract -The fresh `ixian-replacement` profile admits two through six unique classic factions in Basic/Advanced with base Treachery, or native Tleilaxu plus classics in either mode with original Ix47, always with Nexus alone. Native Ixians, other expansion families, Leader Skills, Homeworlds, Semuta, Discoveries, Ecaz variants and other overlays remain excluded. Neither branch retrofits an in-play game, enables a public start, resets a saved room or changes existing native Ixian allied replacement. +The fresh `ixian-replacement` profile admits two through six unique seats in Basic/Advanced: classics/base33, Tleilaxu plus classics/Ix47, or Richese plus classics/CHOAM35 with the separate original cache, always with Nexus alone. Native Ixians, other native families, Leader Skills, Homeworlds, Semuta, Discoveries, Ecaz variants and other overlays remain excluded. No branch retrofits an in-play game, enables a public start, resets a saved room or changes native Ixian allied replacement. There is no starting Nexus deal. Native draws require a qualifying closing Nexus with a settled alliance, so a two-seat profile can continue its base diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 7522fa9c..2147941f 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/base33 or Tleilaxu/classic Ix47 in Basic/Advanced, Ixians absent and Nexus alone. Replace only the normal purchased card with the actual deck top; paid, printed Karama and successful native BG conversion retain original settlement. Native BG counter resolves first; funded winning-payment recovery supplies a paid receipt. Harkonnen uses visibly provisional native order: replacement, original income, original cancelable bonus. No second bonus or eight-card capacity gain. Sequencing and neutral timing are product policy, not publisher priority. Special origins/other overlays stay guarded; no public start or retrofit. | [Base contract](NEXUS_IXIAN_REPLACEMENT_RULES.md#bounded-local-runtime-contract), [Advanced native admission](NEXUS_IXIAN_REPLACEMENT_RULES.md#advanced-native-tleilaxu-admission--16-october-2026), [BG composition](NEXUS_IXIAN_REPLACEMENT_RULES.md#native-bg-converted-purchases--16-october-2026), [provisional Harkonnen order](NEXUS_IXIAN_REPLACEMENT_RULES.md#harkonnen-purchase-and-bonus-composition--16-october-2026) | +| Ixian Nexus Secret Ally replacement | Fresh Basic/Advanced classics/base33, Tleilaxu/classics Ix47, or Richese/classics CHOAM35 plus original cache, with Ixians absent and Nexus alone. Replace only a normal purchased card with the actual deck top and preserve settlement. Native BG counter resolves first; paid recovery supplies a paid receipt. Harkonnen uses provisional replacement → income → cancelable bonus, with no second bonus or capacity gain. Selected card families and native cache membership remain exact. Neutral timing/order are product policy, not publisher priority. Cache/Black Market/special origins and other overlays stay guarded; no public start or retrofit. | [Base contract](NEXUS_IXIAN_REPLACEMENT_RULES.md#bounded-local-runtime-contract), [native Richese scope](NEXUS_IXIAN_REPLACEMENT_RULES.md#native-richese-normal-purchases--16-october-2026), [BG composition](NEXUS_IXIAN_REPLACEMENT_RULES.md#native-bg-converted-purchases--16-october-2026), [provisional Harkonnen order](NEXUS_IXIAN_REPLACEMENT_RULES.md#harkonnen-purchase-and-bonus-composition--16-october-2026) | | Ixian Nexus 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 2888caed..b8a7b80f 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -9910,15 +9910,15 @@ function decideIxianNexusBetrayal(g: Game,id: string,action: Action): void { if (use) log(g,`${getPlayer(g,id).name} spent Ixian Nexus Betrayal to prevent the original native ${frame.source.kind} attempt.`); finishIxianNativeAttempt(g,frame.continuation,use); } -/** Fresh classic or native Tleilaxu profile; no public start or retrofit. */ +/** Fresh classic, Tleilaxu or Richese profile; no public start or retrofit. */ export function initializeIxianNexusReplacementGameForAudit(state: Game): Game { requireRule(!state.nexusIxianReplacementPreview && !state.pendingNexusIxianReplacement && state.nexusIxianReplacementHistory === undefined && state.nexusIxianReplacementCursor === undefined && ixianNexusReplacementModeSupported(state), - 'Ixian Nexus replacement needs a fresh classic/base or Tleilaxu/Ix lobby with Nexus alone.'); + 'Ixian Nexus replacement needs a fresh classic/base, Tleilaxu/Ix or Richese/CHOAM lobby with Nexus alone.'); const g = initializeNexusGameForAudit(state); // Ix supplies Treachery here, not the optional Sandtrout Spice card. - if (g.expansions.length) g.spiceDeck = shuffle(spiceDeck()); + if (g.expansions.includes('ix')) g.spiceDeck = shuffle(spiceDeck()); g.nexusIxianReplacementPreview = true; g.pendingNexusIxianReplacement = null; g.nexusIxianReplacementHistory = []; @@ -9931,7 +9931,10 @@ function ixianNexusReplacementModeSupported(g: Game): boolean { ((!g.expansions.length && g.players.every(p => CLASSIC_FACTIONS[p.faction] === true)) || (g.expansions.length === 1 && g.expansions[0] === 'ix' && g.players.some(p => p.faction === 'tleilaxu') && - g.players.every(p => p.faction === 'tleilaxu' || CLASSIC_FACTIONS[p.faction] === true))) && + g.players.every(p => p.faction === 'tleilaxu' || CLASSIC_FACTIONS[p.faction] === true)) || + (g.expansions.length === 1 && g.expansions[0] === 'choam' && + g.players.some(p => p.faction === 'richese') && + g.players.every(p => p.faction === 'richese' || CLASSIC_FACTIONS[p.faction] === true))) && !g.homeworlds && !g.leaderSkills && !g.discoveryEnabled && !g.discoveries && !g.techTokens && !g.strongholdCards && !g.ecazTreachery && !g.mobileStronghold && !g.sandtrout && !g.kullPreview && !g.nexusKullPreview && !g.semutaPreview && !g.richeseBetrayalPreview && @@ -9942,11 +9945,14 @@ function ixianNexusReplacementParent(g: Game): string { order:g.order,active:g.active,auction:g.auction,sale:g.currentAuctionSale, players:g.players.map(p => ({id:p.id,faction:p.faction,ally:p.ally,spice:p.spice,hand:p.hand})), deck:g.deck,discard:g.discard,aid:g.aid,nexus:g.nexusCards, + ...(byFaction(g, 'richese') ? { richeseBidding:g.richeseBidding, + richeseCache:g.richeseCache,richeseRemoved:g.richeseRemoved } : {}), decision:g.decision,response:g.response,phaseOpening:g.phaseOpening}); } function ixianNexusReplacementContext(g: Game) { requireRule(g.auction, 'The Nexus purchased-card choice lost its normal auction.'); return {status:g.status,phase:g.phase,turn:g.turn,sequence:g.nexusIxianReplacementCursor!.sequence, + expansions:g.expansions,richese:!!byFaction(g,'richese'), auction:g.auction,sale:g.currentAuctionSale ?? null, players:g.players.map(p => ({id:p.id,hand:p.hand,handLimit:handLimit(p)})), physicalCards:physicalTreacheryCards(g)}; diff --git a/game/nexus-ixian-replacement.ts b/game/nexus-ixian-replacement.ts index 29b591ec..39565c13 100644 --- a/game/nexus-ixian-replacement.ts +++ b/game/nexus-ixian-replacement.ts @@ -1,4 +1,5 @@ -import { treacheryDeck, type Card } from './cards'; +import { baseDeck, choamDeck, ixDeck, type Card } from './cards'; +import { richeseCards } from './richese-cards'; export class IxianReplacementError extends Error {} @@ -7,6 +8,8 @@ export type IxianReplacementContext = { phase: number; turn: number; sequence: number; + expansions: readonly string[]; + richese: boolean; auction: { cards: readonly Card[]; index: number; @@ -51,8 +54,16 @@ export type IxianReplacementReceipt = { signature: string; }; -const canonical: Readonly> = - Object.fromEntries(treacheryDeck(['ix']).map(card => [card.id, card])); +// CHOAM shares two physical Ix identities; family membership follows the actual +// catalogues rather than identity prefixes. Build these once, never per card. +function catalogue(cards: readonly Card[]): Readonly> { + return Object.fromEntries(cards.map(card => [card.id, card])); +} +const base = catalogue(baseDeck()); +const ix = catalogue(ixDeck()); +const choam = catalogue(choamDeck()); +const cache = catalogue(richeseCards()); +const canonical: Readonly> = { ...base, ...ix, ...choam, ...cache }; const genesis = 'ixian-replacement:0'; const plain = (value: unknown): value is Record => value !== null && typeof value === 'object' && !Array.isArray(value) && @@ -76,6 +87,14 @@ function canonicalCard(value: unknown): value is Card { value.name === expected.name && value.kind === expected.kind && value.effect === expected.effect; } +function selectedCard(value: unknown, ctx: IxianReplacementContext): value is Card { + return canonicalCard(value) && ( + Object.hasOwn(base, value.id) || + (Object.hasOwn(ix, value.id) && ctx.expansions.includes('ix')) || + (Object.hasOwn(choam, value.id) && ctx.expansions.includes('choam')) || + (Object.hasOwn(cache, value.id) && ctx.expansions.includes('choam') && ctx.richese) + ); +} function sameCard(one: Card, two: Card): boolean { return one.id === two.id && one.name === two.name && one.kind === two.kind && one.effect === two.effect; @@ -116,9 +135,9 @@ function validateSavedSource(value: unknown): asserts value is IxianReplacementS 'The replacement purchase receipt changed its original source.', ); } -function validatePhysical(physical: readonly Card[]): void { +function validatePhysical(physical: readonly Card[], ctx?: IxianReplacementContext): void { requireReplacement( - Array.isArray(physical) && physical.every(canonicalCard) && + Array.isArray(physical) && physical.every(card => ctx ? selectedCard(card, ctx) : canonicalCard(card)) && new Set(physical.map(card => card.id)).size === physical.length, 'Replacement requires canonical cards with single physical custody.', ); @@ -143,14 +162,14 @@ export function validateIxianReplacementSource( sale.winner === source.buyer && sale.amount === source.amount && sale.free === source.free && auction.bidder === source.buyer && auction.bid === source.amount && whole(auction.index) && auction.index === source.auctionIndex && Array.isArray(auction.cards) && - canonicalCard(auction.cards[auction.index]) && sameCard(auction.cards[auction.index], source.card), + selectedCard(auction.cards[auction.index], ctx) && sameCard(auction.cards[auction.index], source.card), 'Replacement must retain its exact original normal auction purchase.', ); - validatePhysical(ctx.physicalCards); + validatePhysical(ctx.physicalCards, ctx); requireReplacement( array(ctx.players) && ctx.players.every(player => plain(player) && text(player.id) && whole(player.handLimit) && player.handLimit > 0 && - Array.isArray(player.hand) && player.hand.every(canonicalCard)) && + Array.isArray(player.hand) && player.hand.every(card => selectedCard(card, ctx))) && new Set(ctx.players.map(player => player.id)).size === ctx.players.length, 'Replacement needs its original buyer and canonical hand custody.', ); diff --git a/game/reference.ts b/game/reference.ts index 4e55e3f2..c04dcbc9 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -4513,7 +4513,7 @@ export const RULE_TOPICS: RuleTopic[] = [ summary: 'In the fresh local ixian-replacement preview, an unallied holder with Ixians absent may spend the physical Ixian Nexus to discard exactly the card just bought in a normal auction and draw the next Treachery deck card privately.', searchText: 'Ixian Nexus Secret Ally purchased card replacement normal auction Karama full hand empty deck same card', steps: [ - 'This fresh local profile admits two-to-six unique classic factions with base Treachery or native Tleilaxu plus classics with original Ix47, in Basic or Advanced, with Ixians absent and Nexus alone. Optional Sandtrout is omitted only at fresh native setup. Original native setup, Face Dancers, actual qualifying Nexus draw and normal purchased-card controls remain. Other native expansions, Leader Skills, Homeworlds, Semuta, Discoveries, Ecaz variants and other overlays remain guarded. No public start or played-save upgrade.', + 'This fresh local profile admits two-to-six unique seats in Basic/Advanced: classics with base33, native Tleilaxu plus classics with Ix47, or native Richese plus classics with CHOAM35 and its separate ten-card cache, always with Ixians absent and Nexus alone. Only normal-auction purchases qualify; original cache setup/auctions remain distinct from a cache-origin replacement. Optional Sandtrout is omitted only for fresh native Ix setup. Other natives, Leader Skills, Homeworlds, Semuta, Discoveries, Ecaz variants and overlays remain guarded. No public start or played-save upgrade.', 'Nexus has no starting deal. A draw requires a qualifying closing Nexus with an alliance. A two-seat profile can continue its base game but cannot naturally create an unallied Secret Ally holder; actual replacement needs at least three seats, or four for two simultaneous unallied recipients.', 'The photographed publisher face grants an independent Secret Ally operation when Ixians are absent. Remain unallied and hold the actual singleton Ixian Nexus. Native Ixian allied replacement is a different cancelable advantage and is not removed or used as a fallback. The adopted unofficial Advanced rulebook supplies relevant core rules, not this absent Nexus face or a new Nexus ruling.', 'Wait for a genuine normal-auction purchase, paid with spice or through Karama, including direct acquisition before a positive bid. A successful native Advanced BG Worthless-as-Karama conversion gets the same independent replacement choice after its native counter resolves. Canceling direct acquisition supplies no purchase; a canceled winning-payment conversion follows native funded spice recovery and, if bought, supplies a paid receipt instead. Cache, Black Market and special-purchase origins retain their separate guarded Nexus scope. Gifts, bonus draws, Technology swaps and old hand cards are not purchases.', @@ -4529,11 +4529,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: 'The bounded profile binds the exact normal purchase, physical replacement and closed history to native settlement. BG conversion and paid recovery remain source-specific. Harkonnen composes replacement before original income/bonus, preserving native bonus cancellation and capacity. Special origins and combined modules remain guarded.', evidence: ['game/nexus-ixian-replacement.ts', 'game/engine.ts', 'tools/prototype-room.ts'] }, - { area: 'Player controls', status: 'Partial', detail: 'Neutral Purchased card choice separates buyer-only inspection and Use/Keep from public waiting. Harkonnen sees the provisional replacement-before-income/bonus explanation. Two original390px journeys exercise Use then bonus cancellation, or Keep then one allowed bonus; no overflow/browser error. Broader acceptance remains open.', evidence: ['components/nexus-ixian-replacement.tsx', 'components/game-table.tsx'] }, - { area: 'AI', status: 'Partial', detail: 'Four minimal legal policies consume only their own projected event while the underlying auction waits. Eight original actions cover BG conversion, eight cover Harkonnen bonus continuation, and eight cover Advanced native Tleilaxu-profile buyers. Full strategy and calibration remain unfinished.', evidence: ['game/bots.ts'] }, - { area: 'Documentation', status: 'Partial', detail: 'Printed physical operation, settlement, full-hand/empty-deck rules and explicit provisional Harkonnen replacement-before-bonus policy are recorded. Cache scope, competing effects and complete-family acceptance remain separate; no publisher priority is invented.', evidence: ['docs/NEXUS_IXIAN_REPLACEMENT_RULES.md', 'docs/NEXUS_CARD_RULES.md', 'docs/RULE_DECISIONS.md'] }, - { area: 'Verification', status: 'Partial', detail: 'Earlier evidence remains historical. BG composition passes affected74/74 and Harkonnen64/64, with their original runtime/control proof. Native Tleilaxu now exercises both modes:7 native cases and affected47/47; four original Advanced Use/Keep paths and eight legal policy actions preserve actual Thumper/Amal purchase, income and next/final-lot continuation. CLI help names both modes. No new UI or saved/deployed assurance; full families/modules remain open.', 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'] }, + { area: 'Implementation', status: 'Partial', detail: 'Normal purchase, physical replacement and closed history bind original settlement. Native Richese adds selected CHOAM35/cache custody without enabling special-origin sales; unselected card families still reject. BG conversion/paid recovery and Harkonnen replacement-before-bonus remain source-specific. Other modules remain guarded.', evidence: ['game/nexus-ixian-replacement.ts', 'game/engine.ts', 'tools/prototype-room.ts'] }, + { area: 'Player controls', status: 'Partial', detail: 'Neutral Purchased card choice separates buyer-only inspection and Use/Keep from waiting. Original390px Richese-family normal purchases exercise Basic Atreides Use and Advanced Richese Keep, preserving original income/cache and next lot. Two tool-only stale-node races were recovered without replay; no application error or overflow. Harkonnen retains its visible provisional ordering. Broader acceptance remains open.', evidence: ['components/nexus-ixian-replacement.tsx', 'components/game-table.tsx'] }, + { area: 'AI', status: 'Partial', detail: 'Four minimal legal policies use only their own projected event while native settlement waits. Sixteen original-state actions cover native Richese normal purchases across both modes and buyer factions; earlier BG/Harkonnen/Tleilaxu paths remain source-qualified in the contract. Full strategy/calibration remain unfinished.', evidence: ['game/bots.ts'] }, + { area: 'Documentation', status: 'Partial', detail: 'Printed replacement, original settlement, selected catalogue/cache membership, full-hand/same-card semantics and provisional Harkonnen order are documented. Native Richese normal purchases are distinct from still-gated cache/Black Market/special origins, competing effects and full-family acceptance; no publisher priority is invented.', evidence: ['docs/NEXUS_IXIAN_REPLACEMENT_RULES.md', 'docs/NEXUS_CARD_RULES.md', 'docs/RULE_DECISIONS.md'] }, + { area: 'Verification', status: 'Partial', detail: 'Earlier source-qualified evidence remains in the contract. Native Richese adds six selected-catalogue and twelve original behavior cases in affected65/65, eight original runtime paths, sixteen legal policy actions and two390px normal-purchase journeys. Original final-lot/cache continuation and the still-gated real cache purchase are exercised. Source repairs follow actual automatic response advancement; typing annotations do not alter behavior. No new build, saved/deployed or broad assurance; all families/modules remain Partial.', evidence: ['tests/nexus-ixian-replacement.test.ts', 'tests/nexus-ixian-replacement-engine.test.ts', 'tests/fixtures/nexus-ixian-native-depleted.json', 'tests/fixture-nexus-ixian-replacement.ts', 'tests/fixture-native-ixian-replacement.ts', 'tests/native-ixian-replacement.test.ts', 'tests/bg-ixian-replacement.test.ts', 'tests/fixture-bg-ixian-replacement.ts', 'tests/harkonnen-ixian-replacement.test.ts', 'tests/fixture-harkonnen-ixian-replacement.ts', 'tests/richese-ixian-replacement.test.ts', 'tests/fixture-richese-ixian-replacement.ts'] }, ], }, { diff --git a/tests/fixture-richese-ixian-replacement.ts b/tests/fixture-richese-ixian-replacement.ts new file mode 100644 index 00000000..c74c564b --- /dev/null +++ b/tests/fixture-richese-ixian-replacement.ts @@ -0,0 +1,297 @@ +import assert from 'node:assert/strict'; +import { + applyAction, createGame, handLimit, initializeIxianNexusReplacementGameForAudit, joinGame, + newPlayer, type Action, type Game, +} from '../game/engine'; +import { choamDeck, treacheryDeck, type Card } from '../game/cards'; +import { richeseCards } from '../game/richese-cards'; +import { nextSpiceBankerIncomeNativeStep } from './fixture-spice-banker-income'; +import type { IxianReplacementFixture } from './fixture-nexus-ixian-replacement'; + +export type RicheseIxianReplacementStep = { actor: string; action: Action }; + +export type RicheseIxianReplacementOptions = { + /** Continue only a fresh authenticated undealt Richese/CHOAM lobby or its admitted setup. */ + initial?: Game; + advanced?: boolean; + /** Seated seat that makes the original normal purchase; Richese may buy too. */ + buyerFaction?: 'atreides' | 'richese'; + /** Shared CHOAM addition bought as an ordinary normal-auction lot. */ + purchasedId?: string; + /** Other shared CHOAM addition left as the real top card after that purchase. */ + replacementId?: string; + /** Physical cache card the genuine turn-one cache lot offers. */ + cacheCardId?: string; + /** Buy the final normal lot so the native pool closes instead of advancing. */ + endingAuction?: boolean; +}; + +export type RicheseIxianReplacementFixture = IxianReplacementFixture & { + /** Admitted fresh setup before its first turn; only undealt decks were ordered. */ + setup: Game; + beforeNexusDraw: Game; + nexusDrawAction: Action; + nextCard: Card; + /** Native normal lot count for this Richese round (the cache reduction). */ + rowSize: number; + advanced: boolean; + expansions: readonly string[]; + factions: string[]; + /** The original ten-card cache minus only the cards a real cache lot kept. */ + cacheAtPurchase: Card[]; + keptCacheCards: string[]; +}; + +/** Native policy for the fresh Richese/CHOAM Nexus profile. Richese's own + * windows come first: the compulsory cache lot is announced last so the normal + * pool leads, no Black Market card is offered, and the turn-one cache lot is a + * real Once Around whose bids are all genuine zero offers. Every other window + * is the ordinary native no-spend policy, with no card, spice or force staged. */ +export function nextRicheseIxianReplacementNativeStep( + game: Game, + cacheCardId?: string, +): RicheseIxianReplacementStep { + const round = game.richeseBidding; + const decision = game.decision; + if (decision?.kind === 'richeseDeclaration') { + assert.ok(round, 'The native Richese round must still be open.'); + return { actor: decision.player, + action: { type: 'decision', event: round.event, position: 'last' } }; + } + if (decision?.kind === 'richeseBlackMarket') { + assert.ok(round, 'A Black Market offer needs its original Richese round.'); + return { actor: decision.player, + action: { type: 'decision', event: round.event, decline: true } }; + } + if (decision?.kind === 'richeseCache') { + const cache = game.richeseCache; + assert.ok(round, 'The cache offer needs its original Richese round.'); + assert.ok(cache?.length, 'The cache offer needs its physical cache.'); + const card = cache.find(candidate => candidate.id === cacheCardId) ?? cache[0]; + return { actor: decision.player, action: { type: 'decision', event: round.event, + card: card.id, method: 'onceAround', direction: 'clockwise' } }; + } + if (decision?.kind === 'richeseUnbid') { + assert.ok(round, 'An unbid cache card needs its original Richese round.'); + const owner = game.players.find(player => player.id === decision.player); + assert.ok(owner, 'The unbid cache card needs its physical owner.'); + return { actor: decision.player, action: { type: 'decision', event: round.event, + keep: owner.hand.length < handLimit(owner) } }; + } + const lot = game.richeseAuction; + if (!decision && lot && !lot.outcome) { + const actor = lot.method === 'silent' + ? lot.order.find(id => lot.eligible.includes(id) && !Object.hasOwn(lot.sealed, id)) + : lot.active; + assert.ok(actor, 'The original Richese lot must still name its next bidder.'); + return { actor, action: { type: 'richeseBid', event: lot.event, + amount: lot.method === 'silent' ? 0 : null } }; + } + const next = nextSpiceBankerIncomeNativeStep(game); + assert.ok(next, 'The native Richese/CHOAM turn must still supply a next step.'); + return next; +} + +function step(game: Game, cacheCardId?: string): Game { + const next = nextRicheseIxianReplacementNativeStep(game, cacheCardId); + return applyAction(game, next.actor, next.action); +} + +/** Only reorders still-undealt original cards; no hand, lot or Nexus receipt is + * manufactured. The requested buyer draws its printed Karama first. */ +function orderStartingCards(game: Game, buyer: string, reserved: readonly string[]): void { + const pool = [...game.deck]; + const front: Card[] = []; + for (const player of game.players) { + const index = pool.findIndex(card => !reserved.includes(card.id) && + (player.id === buyer ? card.effect === 'karama' : card.effect !== 'karama')); + assert.ok(index >= 0, 'A conserved original starting face must remain undealt.'); + front.push(pool.splice(index, 1)[0]); + } + game.deck = [...front, ...pool.filter(card => !reserved.includes(card.id)), + ...pool.filter(card => reserved.includes(card.id))]; +} + +/** Proves the purchase and the replacement stay inside their own original + * inventories: the shared CHOAM deck is conserved apart from the ten-card + * Richese cache, which is conserved with whatever a real cache lot kept. */ +function assertOriginalInventory(game: Game): void { + const physical = [ + ...game.players.flatMap(player => player.hand), ...game.deck, ...game.discard, + ...(game.auction?.cards.slice(game.auction.index) ?? []), + ]; + const cache = physical.filter(card => card.id.startsWith('richese-')); + assert.deepEqual(physical.filter(card => !cache.includes(card)).map(card => card.id).sort(), + treacheryDeck(['choam']).map(card => card.id).sort()); + assert.deepEqual([...game.richeseCache!.map(card => card.id), + ...(game.richeseRemoved ?? []).map(card => card.id), ...cache.map(card => card.id)].sort(), + richeseCards().map(card => card.id).sort()); +} + +/** Fresh native Richese + classics with CHOAM alone: a real first turn, the + * genuine closing Nexus draw by an unallied buyer, and an original paid normal + * purchase of a shared CHOAM addition. Richese's own cache stays a separate + * ten-card collection (the compulsory cache lot runs Once Around and is kept, + * never bought through the Nexus replacement). Returned before the payment. */ +export function createRicheseIxianReplacementFixture( + options: RicheseIxianReplacementOptions = {}, +): RicheseIxianReplacementFixture { + const purchasedId = options.purchasedId ?? 'ix-poison-tooth'; + const replacementId = options.replacementId ?? 'ix-artillery'; + assert.notEqual(purchasedId, replacementId); + for (const id of [purchasedId, replacementId]) + assert.ok(choamDeck().some(card => card.id === id), + 'Only an original shared CHOAM addition may be bought or become the replacement.'); + let game: Game; + if (options.initial) { + game = structuredClone(options.initial); + assert.ok(game.status === 'lobby' || (game.status === 'setup' && + game.nexusIxianReplacementPreview === true && game.turn === 1 && game.phase === 0 && + game.players.every(player => player.hand.length === 0)), + 'Continue only an admitted fresh lobby or its undealt native prototype setup.'); + } else { + game = createGame('RICNIXREP', newPlayer('richese', 'Richese', 'richese'), + options.advanced ?? false, ['choam']); + joinGame(game, newPlayer('atreides', 'Atreides', 'atreides')); + joinGame(game, newPlayer('emperor', 'Emperor', 'emperor')); + } + if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced); + assert.deepEqual(game.expansions, ['choam'], 'This profile is CHOAM alone, with no Ix deck.'); + const buyerSeat = game.players.find(player => player.faction === (options.buyerFaction ?? 'atreides')); + assert.ok(buyerSeat, 'The requested buyer faction must be seated in the Richese roster.'); + assert.ok(game.players.some(player => player.faction === 'richese'), + 'The native profile needs its seated Richese.'); + const buyer = buyerSeat.id; + const partners = game.players.filter(player => player.id !== buyer); + assert.equal(partners.length, 2, 'The closing Nexus needs two other seats to ally.'); + if (game.status === 'lobby') { + for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' }); + game.nexusCards ??= { cards: null, phase: null }; + game = initializeIxianNexusReplacementGameForAudit(game); + } + const cache = game.richeseCache; + assert.ok(cache, 'The admitted Richese profile must own its physical cache.'); + assert.equal(cache.length, richeseCards().length); + const cacheCardId = options.cacheCardId ?? cache[0].id; + assert.ok(cache.some(card => card.id === cacheCardId), + 'The chosen cache card must be one of the original ten.'); + assert.ok(game.deck.some(card => card.id === purchasedId)); + assert.ok(game.deck.some(card => card.id === replacementId)); + orderStartingCards(game, buyer, [purchasedId, replacementId]); + const nexus = game.nexusCards!.cards; + assert.ok(nexus, 'The fresh Nexus profile must own its physical cards.'); + assert.ok(Object.values(nexus.hands).every(face => face === null), + 'No Nexus card is held before the closing Nexus.'); + assert.ok(nexus.deck.includes('ixians')); + nexus.deck = ['ixians', ...nexus.deck.filter(face => face !== 'ixians')]; + const setup = structuredClone(game); + for (let count = 0; game.status === 'setup' && count < 200; count++) game = step(game, cacheCardId); + assert.equal(game.status, 'playing'); + assert.ok(game.players.every(player => player.hand.length === 1), + 'Every seat holds exactly its one original starting card.'); + assert.ok(game.players.find(player => player.id === buyer)!.hand.some(card => card.effect === 'karama'), + 'The buyer holds its printed Karama while still paying its own spice.'); + // Only the two still-unplayed Spice positions are ordered so the first turn + // cannot supply the closing worm; every later card is native. + for (let position = 0; position < 2; position++) { + const index = game.spiceDeck.findIndex((card, at) => at >= position && 'territory' in card); + assert.ok(index >= position, 'A conserved original territory Spice Card must remain.'); + game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]); + } + const turnOne = game.turn; + for (let count = 0; count < 1200; count++) { + if (game.turn > turnOne && game.phase === 0 && !game.phaseOpening && !game.response && !game.decision) break; + game = step(game, cacheCardId); + } + assert.equal(game.turn, 2, 'A complete native first turn must supply the next Storm.'); + assert.equal(game.richeseCache!.length, richeseCards().length - 1, + 'Exactly one compulsory cache lot ran, keeping its single original card.'); + for (let count = 0; game.phase !== 1 && count < 300; count++) game = step(game, cacheCardId); + const worm = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker && !card.suppressed); + assert.ok(worm >= 0, 'An original worm Spice Card must remain for the closing Nexus.'); + game.spiceDeck.splice(0, 0, game.spiceDeck.splice(worm, 1)[0]); + for (let count = 0; !(game.nexus && !game.spiceWindow && !game.spiceResolution && + !game.phaseOpening && !game.response && !game.decision) && count < 300; count++) game = step(game, cacheCardId); + assert.equal(game.phase, 1); + assert.equal(game.nexus, true); + // A real alliance between the two non-recipients leaves the buyer unallied. + game = applyAction(game, partners[0].id, { type: 'alliance', target: partners[1].id }); + game = applyAction(game, partners[1].id, { type: 'alliance', target: partners[0].id }); + for (let count = 0; game.nexusCards?.phase?.stage !== 'drawing' && count < 300; count++) game = step(game, cacheCardId); + assert.equal(game.nexusCards?.phase?.stage, 'drawing'); + const beforeNexusDraw = structuredClone(game); + const nexusDrawAction: Action = { type: 'nexusCardChoice', turn: game.turn, card: null, choice: 'draw', ownRedraws: 0 }; + game = applyAction(game, buyer, nexusDrawAction); + assert.equal(game.nexusCards!.cards!.hands[buyer], 'ixians'); + for (const id of game.nexusCards!.phase!.eligible) + if (!game.nexusCards!.phase!.done.includes(id)) + game = applyAction(game, id, { type: 'nexusCardChoice', turn: game.turn, card: null, choice: 'keep', ownRedraws: 0 }); + // Reach the genuine Richese declaration of the auction turn, then order only + // the still-undealt deck before the native row allocation consumes it. + for (let count = 0; game.decision?.kind !== 'richeseDeclaration' && count < 400; count++) game = step(game, cacheCardId); + const declaration = game.decision; + assert.equal(declaration?.kind, 'richeseDeclaration'); + const round = game.richeseBidding!; + const rowSize = game.richeseBidding!.normalCount!; + assert.ok(Number.isSafeInteger(rowSize) && rowSize >= 1, + 'The original Richese round must schedule at least one normal lot.'); + const purchaseIndex = options.endingAuction ? rowSize - 1 : 0; + const pool = [...game.deck]; + const reserve = (id: string): Card => { + const at = pool.findIndex(card => card.id === id); + assert.ok(at >= 0, `The still-undealt deck must hold ${id}.`); + return pool.splice(at, 1)[0]; + }; + const originalPurchase = reserve(purchasedId); + const originalReplacement = reserve(replacementId); + game.deck = [...pool]; + game.deck.splice(purchaseIndex, 0, originalPurchase); + // Native allocation takes one lot per scheduled seat; the remaining deck top + // is the requested original replacement, never a reserved physical lot. + game.deck.splice(rowSize, 0, originalReplacement); + game = applyAction(game, declaration.player, { type: 'decision', event: round.event, position: 'last' }); + for (let count = 0; !(game.decision?.kind === 'auctionPayment' && game.decision.player === buyer) && count < 400; count++) { + if (game.decision?.kind === 'auctionPayment') + game = applyAction(game, game.decision.player, { type: 'decision', karama: false }); + else if (game.phaseOpening || game.response || game.decision || !game.auction) game = step(game, cacheCardId); + else if (game.auction.index < purchaseIndex) { + const active = game.auction.active; + game = applyAction(game, active, active !== buyer && game.auction.bid === 0 + ? { type: 'bid', amount: 1 } : { type: 'passBid' }); + } else if (game.auction.active === buyer && game.auction.bid === 0) + game = applyAction(game, buyer, { type: 'bid', amount: 2 }); + else game = applyAction(game, game.auction.active, { type: 'passBid' }); + } + assert.deepEqual(game.decision, { kind: 'auctionPayment', player: buyer }); + const auction = game.auction!; + assert.equal(auction.cards[auction.index].id, purchasedId); + assert.equal(auction.bid, 2); + assert.equal(auction.allyPayment ?? 0, 0); + assert.equal(game.deck[0].id, replacementId); + assert.equal(game.richeseBidding!.stage, 'normal'); + assertOriginalInventory(game); + const beforePayment = structuredClone(game); + const owner = game.players.find(player => player.id === buyer)!; + const emperor = game.players.find(player => player.faction === 'emperor')?.id ?? null; + const beforeEmperorSpice = emperor ? game.players.find(player => player.id === emperor)!.spice : null; + const beforeSpice = owner.spice; + const purchased = structuredClone(auction.cards[auction.index]); + const cacheAtPurchase = structuredClone(game.richeseCache!); + const keptCacheCards = game.players.flatMap(player => player.hand) + .filter(card => card.id.startsWith('richese-')).map(card => card.id); + const paymentAction: Action = { type: 'decision', karama: false }; + game = applyAction(game, buyer, paymentAction); + assert.ok(game.pendingNexusIxianReplacement, 'The normal purchase must offer the Nexus replacement.'); + assert.equal(game.pendingNexusIxianReplacement.buyer, buyer); + assert.equal(game.decision?.kind, 'nexusIxianReplacement'); + assert.deepEqual(game.richeseCache, cacheAtPurchase, 'The purchase must not touch the Richese cache.'); + return { + setup, beforeNexusDraw, nexusDrawAction, beforePayment, game, buyer, + event: game.pendingNexusIxianReplacement.event, purchased, paymentAction, beforeSpice, + paidSpice: game.players.find(player => player.id === buyer)!.spice, emperor, beforeEmperorSpice, + nextCard: structuredClone(game.deck[0]), auctionIndex: beforePayment.auction!.index, + rowSize, advanced: game.advanced, expansions: game.expansions, + factions: game.players.map(player => player.faction), + cacheAtPurchase, keptCacheCards, + }; +} diff --git a/tests/native-ixian-replacement.test.ts b/tests/native-ixian-replacement.test.ts index fc12da88..c6fc7119 100644 --- a/tests/native-ixian-replacement.test.ts +++ b/tests/native-ixian-replacement.test.ts @@ -93,6 +93,7 @@ void test('native descriptor admission rejects forged Ix effects and unrelated R ] as Card[]) { const ctx: IxianReplacementContext = { status: f.game.status, phase: f.game.phase, turn: f.game.turn, sequence: source.sequence, + expansions: f.game.expansions, richese: false, 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.test.ts b/tests/nexus-ixian-replacement.test.ts index 2a40a8fe..20a9c10c 100644 --- a/tests/nexus-ixian-replacement.test.ts +++ b/tests/nexus-ixian-replacement.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { baseDeck, type Card } from '../game/cards'; +import { baseDeck, choamDeck, ixDeck, treacheryDeck, type Card } from '../game/cards'; +import { richeseCards } from '../game/richese-cards'; import { IxianReplacementError, closeIxianReplacementSource, @@ -19,6 +20,7 @@ const parent = 'original-native-paid-sale-parent'; function context(): IxianReplacementContext { return { status: 'playing', phase: 3, turn: 1, sequence: 0, + expansions: [], richese: false, auction: { cards: [{ ...purchased }, { ...unsold }], index: 0, bidder: 'buyer', bid: 4 }, sale: { winner: 'buyer', amount: 4, free: false, origin: 'normal', seller: null }, players: [ @@ -28,6 +30,17 @@ function context(): IxianReplacementContext { physicalCards: [{ ...older }, { ...purchased }, { ...top }, { ...unsold }], }; } +function purchaseContext( + card: Card, expansions: readonly string[], richese = false, +): IxianReplacementContext { + const ctx = context(); + ctx.expansions = expansions; + ctx.richese = richese; + ctx.auction!.cards = [{ ...card }]; + ctx.players = [{ ...ctx.players[0], hand: [{ ...card }] }, ctx.players[1]]; + ctx.physicalCards = [{ ...card }]; + return ctx; +} function source(ctx: IxianReplacementContext = context()) { return createIxianReplacementSource( ctx, ixianReplacementEvent(ctx.turn, 'buyer', ctx.sequence, 0), parent, @@ -84,6 +97,115 @@ void test('canonical descriptors cannot be redefined by a matching auction row a } }); +void test('selected CHOAM retains all original 35 faces alongside separate native Richese custody', () => { + const deck = treacheryDeck(['choam']); + const cache = richeseCards(); + assert.equal(deck.length, 35); + assert.equal(cache.length, 10); + assert.deepEqual(choamDeck().map(card => card.id).sort(), ['ix-artillery', 'ix-poison-tooth']); + for (const card of deck) { + const ctx = purchaseContext(card, ['choam'], true); + ctx.physicalCards = [...deck, ...cache]; + assert.deepEqual(source(ctx).card, card); + } +}); + +void test('base and Ix families remain selected by actual membership, including shared CHOAM identities', () => { + for (const card of baseDeck()) { + assert.deepEqual(source(purchaseContext(card, [])).card, card); + assert.deepEqual(source(purchaseContext(card, ['choam'])).card, card); + } + const shared = choamDeck(); + for (const card of ixDeck()) { + assert.deepEqual(source(purchaseContext(card, ['ix'])).card, card); + assert.deepEqual(source(purchaseContext(card, ['ix', 'choam'])).card, card); + assert.throws(() => source(purchaseContext(card, [])), IxianReplacementError); + const choamOnly = purchaseContext(card, ['choam']); + if (shared.some(member => member.id === card.id)) + assert.deepEqual(source(choamOnly).card, card); + else + assert.throws(() => source(choamOnly), IxianReplacementError); + } +}); + +void test('every original cache face requires both selected CHOAM and native Richese even after normal recycling', () => { + for (const card of richeseCards()) { + const ctx = purchaseContext(card, ['choam'], true); + assert.deepEqual(source(ctx).card, card); + for (const expansions of [[], ['ix'], ['choam'], ['ix', 'choam']]) { + for (const richese of [false, true]) { + if (expansions.includes('choam') && richese) continue; + assert.throws(() => source(purchaseContext(card, expansions, richese)), IxianReplacementError); + } + } + for (const origin of ['cache', 'blackMarket'] as const) { + const special = purchaseContext(card, ['choam'], true); + special.sale = { ...special.sale!, origin }; + assert.throws(() => source(special), IxianReplacementError); + } + } +}); + +void test('an ordinary purchase cannot hide unsupported card families elsewhere in live custody', () => { + for (const card of [...ixDeck(), ...richeseCards()]) { + const ctx = context(); + ctx.physicalCards = [...ctx.physicalCards, card]; + assert.throws(() => source(ctx), IxianReplacementError); + } + for (const card of richeseCards()) { + for (const [expansions, richese] of [ + [['choam'], false], [['ix'], true], [[], true], + ] as const) { + const ctx = context(); + ctx.expansions = expansions; + ctx.richese = richese; + ctx.players = [ctx.players[0], { ...ctx.players[1], hand: [card] }]; + ctx.physicalCards = [...ctx.physicalCards, card]; + assert.throws(() => source(ctx), IxianReplacementError); + } + } +}); + +void test('CHOAM and cache faces reject altered descriptors even with matching row, hand and custody', () => { + for (const card of [...treacheryDeck(['choam']), ...richeseCards()]) { + const altered: Card[] = [ + { ...card, name: `${card.name} altered` }, + { ...card, kind: card.kind === 'special' ? 'projectile' : 'special' }, + { ...card, effect: 'fabricated' }, + { ...card, id: `${card.id}-copy` }, + { ...card, extra: true } as Card, + ]; + if (card.effect !== undefined) { + const missingEffect = { ...card }; + delete missingEffect.effect; + altered.push(missingEffect); + } + for (const corrupt of altered) + assert.throws(() => source(purchaseContext(corrupt, ['choam'], true)), IxianReplacementError); + } +}); + +void test('historical receipts admit the full canonical union without weakening live family gates', () => { + const [shared] = choamDeck(); + const [cache] = richeseCards(); + const original = source(purchaseContext(shared, ['choam'], true)); + const first = closeIxianReplacementSource(original, 'use', cache, initialIxianReplacementCursor()); + const later = purchaseContext(cache, ['choam'], true); + later.turn = 2; + later.sequence = first.cursor.sequence; + const cacheSource = source(later); + const second = closeIxianReplacementSource(cacheSource, 'use', cache, first.cursor); + const history = JSON.parse(JSON.stringify([first.receipt, second.receipt])); + validateIxianReplacementHistory(history, second.cursor, [...treacheryDeck(['ix']), ...richeseCards()]); + assert.equal(second.receipt.replacement?.id, cacheSource.card.id); + assert.throws(() => validateIxianReplacementSource( + { ...later, richese: false }, cacheSource, parent, + ), IxianReplacementError); + assert.throws(() => validateIxianReplacementHistory(history, second.cursor, [ + ...treacheryDeck(['ix']), ...richeseCards(), { ...cache }, + ]), IxianReplacementError); +}); + void test('duplicate physical cards reject even when the purchased hand itself looks legal', () => { for (const duplicate of [purchased, older, top, unsold]) { const ctx = context(); diff --git a/tests/richese-ixian-replacement.test.ts b/tests/richese-ixian-replacement.test.ts new file mode 100644 index 00000000..f68abee7 --- /dev/null +++ b/tests/richese-ixian-replacement.test.ts @@ -0,0 +1,172 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { richeseCards } from '../game/richese-cards'; +import { settleIxianReplacementFixture } from './fixture-nexus-ixian-replacement'; +import { + createRicheseIxianReplacementFixture, nextRicheseIxianReplacementNativeStep, + type RicheseIxianReplacementFixture, +} from './fixture-richese-ixian-replacement'; + +const owner = (game: Game, id: string) => game.players.find(player => player.id === id)!; +const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)); +function reject(game: Game, id: string, action: Action): void { + const before = structuredClone(game); + assert.throws(() => applyAction(game, id, action)); + assert.deepEqual(game, before); +} + +const BUILT: Record = {}; +/** One genuine native Richese run per roster/mode; each test reloads its own copy. */ +function fixtureFor( + advanced: boolean, + buyerFaction: 'atreides' | 'richese', + endingAuction = false, +): RicheseIxianReplacementFixture { + const key = `${advanced}:${buyerFaction}:${endingAuction}`; + const held = BUILT[key]; + if (held) return held; + const created = createRicheseIxianReplacementFixture({ advanced, buyerFaction, endingAuction }); + BUILT[key] = created; + return created; +} + +for (const advanced of [false, true]) +for (const buyerFaction of ['atreides', 'richese'] as const) + void test(`${advanced ? 'Advanced' : 'Basic'} native ${buyerFaction} normal CHOAM purchase spends the Nexus for the real top replacement`, () => { + const f = fixtureFor(advanced, buyerFaction); + assert.equal(f.purchased.id, 'ix-poison-tooth'); + assert.equal(f.nextCard.id, 'ix-artillery'); + assert.equal(f.rowSize, 2); + // The cache stayed its own original collection: only the one genuine Once + // Around keep left it, and the normal purchase itself never touched it. + assert.equal(f.keptCacheCards.length, 1); + assert.ok(f.setup.richeseCache!.some(card => card.id === f.keptCacheCards[0])); + assert.equal(f.cacheAtPurchase.length, richeseCards().length - 1); + assert.equal(f.beforePayment.currentAuctionSale, null); + assert.equal(f.beforePayment.auction!.cards[f.auctionIndex].id, f.purchased.id); + const offer = viewGame(f.game, f.buyer).nexusIxianReplacement!; + assert.equal(offer.canUse, true); + assert.equal(offer.canPass, true); + assert.deepEqual(offer.purchased, f.purchased); + assert.equal(owner(f.game, f.buyer).spice, f.beforeSpice - 2); + assert.equal(owner(f.game, f.emperor!).spice, f.beforeEmperorSpice); + const partners = f.beforeNexusDraw.players.filter(player => player.id !== f.buyer); + for (const partner of partners) + for (const type of ['nexusIxianReplacementUse', 'nexusIxianReplacementPass'] as const) + reject(f.game, partner.id, { type, event: offer.event }); + + const deckBefore = structuredClone(f.game.deck); + const unsold = structuredClone(f.game.auction!.cards.slice(f.auctionIndex + 1)); + const use = settleIxianReplacementFixture(applyAction(reload(f.game), f.buyer, + { type: 'nexusIxianReplacementUse', event: offer.event })); + assert.deepEqual(owner(use, f.buyer).hand, [...owner(f.beforePayment, f.buyer).hand, f.nextCard]); + assert.deepEqual(use.deck, deckBefore.slice(1)); + assert.deepEqual(use.discard, [...f.game.discard, f.purchased]); + assert.equal(use.nexusCards!.cards!.hands[f.buyer], null); + assert.deepEqual(use.nexusCards!.cards!.discard, [...f.game.nexusCards!.cards!.discard, 'ixians']); + assert.equal(owner(use, f.buyer).spice, f.paidSpice); + assert.equal(owner(use, f.emperor!).spice, f.beforeEmperorSpice! + 2); + assert.equal(use.currentAuctionSale, null); + assert.equal(use.pendingNexusIxianReplacement, null); + assert.deepEqual(use.richeseCache, f.cacheAtPurchase); + assert.equal(use.richeseBidding!.stage, 'normal'); + assert.equal(use.auction!.index, f.auctionIndex + 1); + assert.deepEqual(use.auction!.cards.slice(use.auction!.index), unsold); + reject(use, f.buyer, { type: 'nexusIxianReplacementUse', event: f.event }); + }); + +for (const advanced of [false, true]) +for (const buyerFaction of ['atreides', 'richese'] as const) + void test(`${advanced ? 'Advanced' : 'Basic'} native ${buyerFaction} normal CHOAM purchase retains its card and Nexus card`, () => { + const f = fixtureFor(advanced, buyerFaction); + const offer = viewGame(f.game, f.buyer).nexusIxianReplacement!; + const pass = settleIxianReplacementFixture(applyAction(reload(f.game), f.buyer, + { type: 'nexusIxianReplacementPass', event: offer.event })); + assert.deepEqual(owner(pass, f.buyer).hand, owner(f.game, f.buyer).hand); + assert.ok(owner(pass, f.buyer).hand.some(card => card.id === f.purchased.id)); + assert.deepEqual(pass.deck, f.game.deck); + assert.deepEqual(pass.discard, f.game.discard); + assert.deepEqual(pass.nexusCards!.cards, f.game.nexusCards!.cards); + assert.equal(pass.nexusCards!.cards!.hands[f.buyer], 'ixians'); + assert.equal(owner(pass, f.buyer).spice, f.paidSpice); + assert.equal(owner(pass, f.emperor!).spice, f.beforeEmperorSpice! + 2); + assert.equal(pass.currentAuctionSale, null); + assert.equal(pass.pendingNexusIxianReplacement, null); + assert.deepEqual(pass.richeseCache, f.cacheAtPurchase); + assert.equal(pass.auction!.index, f.auctionIndex + 1); + }); + +for (const advanced of [false, true]) +void test(`${advanced ? 'Advanced' : 'Basic'} native final-lot CHOAM purchase closes the pool into the untouched Richese cache offer`, () => { + const f = fixtureFor(advanced, 'atreides', true); + assert.equal(f.auctionIndex, f.rowSize - 1); + assert.equal(f.purchased.id, 'ix-poison-tooth'); + assert.equal(f.nextCard.id, 'ix-artillery'); + const offer = viewGame(f.game, f.buyer).nexusIxianReplacement!; + assert.deepEqual(offer.purchased, f.purchased); + const richese = f.game.players.find(player => player.faction === 'richese')!.id; + const use = settleIxianReplacementFixture(applyAction(reload(f.game), f.buyer, + { type: 'nexusIxianReplacementUse', event: offer.event })); + assert.deepEqual(owner(use, f.buyer).hand, [...owner(f.beforePayment, f.buyer).hand, f.nextCard]); + assert.deepEqual(use.discard, [...f.game.discard, f.purchased]); + assert.equal(owner(use, f.buyer).spice, f.paidSpice); + assert.equal(owner(use, f.emperor!).spice, f.beforeEmperorSpice! + 2); + assert.equal(use.auction, null); + assert.deepEqual(use.decision, { kind: 'richeseCache', player: richese }); + assert.equal(use.richeseBidding!.stage, 'cacheOffer'); + assert.equal(use.richeseBidding!.cacheCanceled, false); + assert.deepEqual(use.richeseCache, f.cacheAtPurchase); + assert.equal(use.pendingNexusIxianReplacement, null); + assert.equal(use.nexusCards!.cards!.hands[f.buyer], null); +}); + +for (const advanced of [false, true]) +void test(`${advanced ? 'Advanced' : 'Basic'} Richese cache lot keeps the Nexus replacement gated to normal purchases`, () => { + const f = fixtureFor(advanced, 'atreides', true); + const offer = viewGame(f.game, f.buyer).nexusIxianReplacement!; + const richese = f.game.players.find(player => player.faction === 'richese')!.id; + // Keep the purchased card and the unallied Nexus card, so the buyer is still + // an eligible Nexus holder when the round reaches its own compulsory cache lot. + let game = settleIxianReplacementFixture(applyAction(reload(f.game), f.buyer, + { type: 'nexusIxianReplacementPass', event: offer.event })); + assert.equal(game.nexusCards!.cards!.hands[f.buyer], 'ixians'); + assert.equal(game.auction, null); + assert.equal(game.richeseBidding!.stage, 'cacheOffer'); + assert.deepEqual(game.decision, { kind: 'richeseCache', player: richese }); + assert.equal(game.pendingNexusIxianReplacement, null); + const cacheCard = game.richeseCache![0]; + const before = { buyer: owner(game, f.buyer).spice, richese: owner(game, richese).spice, + emperor: owner(game, f.emperor!).spice }; + game = applyAction(game, richese, { type: 'decision', event: game.richeseBidding!.event, + card: cacheCard.id, method: 'onceAround', direction: 'clockwise' }); + assert.equal(game.richeseAuction?.source, 'cache'); + assert.equal(game.richeseAuction?.cardId, cacheCard.id); + assert.equal(game.richeseAuction?.owner, richese); + // A real Once Around: the native policy declines for every other seat, the + // unallied Nexus holder buys the cache card with its own spice. + for (let count = 0; game.richeseAuction && !game.richeseAuction.outcome && count < 20; count++) { + const lot: NonNullable = game.richeseAuction; + const next = nextRicheseIxianReplacementNativeStep(game); + assert.equal(next.actor, lot.active); + assert.equal(game.pendingNexusIxianReplacement, null); + game = next.actor === f.buyer + ? applyAction(game, f.buyer, { type: 'richeseBid', event: lot.event, + amount: lot.bid === 0 ? 1 : null }) + : applyAction(game, next.actor, next.action); + } + // The cache purchase retires its own lot with no replacement frame, while the + // same buyer's normal purchase above did open one. + assert.equal(game.richeseAuction, null); + assert.equal(game.pendingNexusIxianReplacement, null); + assert.equal(game.decision, null); + assert.equal(game.currentAuctionSale, null); + assert.ok(owner(game, f.buyer).hand.some(card => card.id === cacheCard.id)); + assert.ok(!game.richeseCache!.some(card => card.id === cacheCard.id)); + assert.equal(owner(game, f.buyer).spice, before.buyer - 1); + assert.equal(owner(game, richese).spice, before.richese + 1); + assert.equal(owner(game, f.emperor!).spice, before.emperor); + assert.equal(game.nexusCards!.cards!.hands[f.buyer], 'ixians'); + assert.equal(game.richeseBidding!.stage, 'complete'); + assert.equal(game.phase, 4); +}); diff --git a/tools/start-prototype.ts b/tools/start-prototype.ts index 62eb4a51..509e7e75 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 a fresh classic Basic/Advanced base-deck roster, or Basic/Advanced Tleilaxu plus classic opponents with the original Ix47 deck and no optional Sandtrout, into the exact normal-auction purchased-card replacement with Nexus alone. Native Ixians and special purchase sources remain unsupported. Harkonnen uses the visible provisional replacement-before-income/bonus order. Ixian-betrayal opts a fresh 2–6-seat Basic/Advanced native Ixian plus classic/Tleilaxu roster with Ix 47-card Treachery and Nexus only into prevention of one actual native Bidding draw or Advanced Technology exchange, after native Karama counters. Harkonnen-betrayal opts a fresh 2–6-seat classic Basic/Advanced roster with native Harkonnen, base 33-card Treachery and Nexus alone into cancellation of an actual declared personal or allied Harkonnen Traitor Card after native counters, physical retirement and shuffle, and one private actual Mentat replacement. Modules, overlays, public starts and old-game retrofits remain excluded except for each profile’s documented composition. Nexus is not dealt initially: a qualifying alliance requires at least three seats; two unallied receivers require four. Backs up all rooms first, preserves sessions and existing games, and rejects stale versions. Normal game-start and publication gates remain closed.", + "Usage: node --import tsx tools/start-prototype.ts --profile ix|discovery|homeworld-occupation|leader-skills|banker-income|factions|stronghold-factions|nexus|moritani-assassinate|ecaz-treachery|ecaz-occupy|semuta|kull|richese-betrayal|nexus-kull|guild-betrayal|ixian-replacement|ixian-betrayal|harkonnen-betrayal --db PATH --room CODE --version NUMBER --out /private/new-directory\nStarts only a fresh ready lobby for the selected development prototype. Stronghold-factions opts a fresh ready 2–6-seat Advanced roster with at least one selected native Ixians, Tleilaxu, CHOAM or Richese faction and otherwise classic opponents into Stronghold Cards, optionally preserving unused Tech Tokens at 3–6 seats. Select one or both distinct Ix/CHOAM decks; each native faction requires its family deck. The selected decks supply 47 Ix or combined, or 35 CHOAM canonical physical Treachery Cards, with a separate ten-card Richese cache when Richese plays. Native setup choices, HMS placement, Face Dancers, Richese cache and CHOAM Auditor remain genuine; custody starts unowned and is claimed by actual END Mentat. It does not redeal, reset or retrofit a saved game. Banker-income opts a fresh 2–6-seat classic Basic/Advanced base-deck roster, supported Basic Ixian/Tleilaxu/CHOAM/Moritani skill roster with its single required deck, or Advanced native Ixians, Tleilaxu and/or CHOAM plus classic roster with distinct required family decks, into automatic deferred normal income and native Mentat collection with all fourteen Leader Skills. Original bank payer costs exclude the separate Tleilaxu free-revival award; already-earned spice stays with its original faction under the existing provisional policy. Nexus enables the existing private Nexus setup. Moritani-assassinate explicitly opts into the bounded Advanced assassination preview. Semuta opts a fresh Richese roster into bounded clean-discard reactions; an included Ix deck supplies physical Thumper without opening public starts. Kull opts a fresh eligible CHOAM roster into the user-selected Karama interception preview. Richese-betrayal opts a fresh paired CHOAM/Richese Nexus roster into cache-purchase veto and sale-bank diversion. Nexus-kull opts a fresh CHOAM-plus-classic roster with CHOAM/Ix decks and Nexus into source-aware printed or any-Treachery-fueled Cunning Kull. Guild-betrayal opts a fresh classic roster with native Guild into full funded shipment-payment replacement; it preserves an explicitly enabled Homeworld module for Junction routes. Ixian-replacement opts fresh classic/base33, native Tleilaxu plus classics/Ix47 without optional Sandtrout, or native Richese plus classics/CHOAM35 with the separate original ten-card cache, in Basic/Advanced with Nexus alone. Only normal-auction purchased-card replacement is connected; cache, Black Market and special-purchase origins remain excluded. Native Ixians and special purchase sources remain unsupported. Harkonnen uses the visible provisional replacement-before-income/bonus order. Ixian-betrayal opts a fresh 2–6-seat Basic/Advanced native Ixian plus classic/Tleilaxu roster with Ix 47-card Treachery and Nexus only into prevention of one actual native Bidding draw or Advanced Technology exchange, after native Karama counters. Harkonnen-betrayal opts a fresh 2–6-seat classic Basic/Advanced roster with native Harkonnen, base 33-card Treachery and Nexus alone into cancellation of an actual declared personal or allied Harkonnen Traitor Card after native counters, physical retirement and shuffle, and one private actual Mentat replacement. Modules, overlays, public starts and old-game retrofits remain excluded except for each profile’s documented composition. Nexus is not dealt initially: a qualifying alliance requires at least three seats; two unallied receivers require four. Backs up all rooms first, preserves sessions and existing games, and rejects stale versions. Normal game-start and publication gates remain closed.", '\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.',