diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 7020ac46..910bfdb2 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,6 +17,30 @@ open mode gates. ## Current checkpoint and work +**Mixed E1/E2 and paired E3 Nexus — source-qualified prototype checkpoint.** +Original both-family Ix+CHOAM/union47 or both Ecaz+Moritani/ecaz33/classic +entries preserve Skills or no Skills, original Homeworld/Discovery/Tech3+/ +Advanced Strongholds2+.312 original admissions reach setup versus four +before; actual Ix pre-training offers and ordinary native setup remain. +New31 physical programmes plus two Loyalty-stock regressions and the +final489/489 affected checks across42 files pass with types/lint/build. +Native Ecaz assassination, separately held physical Loyalty +stock and exact paired original overlap admission repair real consumer +omissions, without new printed rulings or public activation. + +Across64 original six-seat families,258/288 finish338,931accepted/ +339,426attempted/9,013JSON across three frozen trees and preserved-prefix +continuations. Every original/continuation source receipt and all30 +remaining source-guard traces are read. This is not288 uninterrupted passes. +Actual390px original native human setup/offers, Maker/both piles and card +draws, typed paid Ixian visitor/free Suboid plan and Ecaz foreign arrival/ +separate living Duke preserve20 counters in **3AVHJN76 v765** and +**PSDLV4FJ v121**. Losing Ixians receive no winner-only Suk rescue; no missed +human nested entry or Moritani reveal is invented. The final union and +compiled bundle and actual390px guide pass; checkpoint push/exact CI follow. +Protected deployment and complete modes remain gated. +[Canonical qualified evidence](NEXUS_CARD_RULES.md#mixed-e1e2-and-paired-e3-nexus--7-october-2026). + **Single-native E1/E2 Nexus — bounded Development Verified.** Original Ixians OR Tleilaxu OR CHOAM OR Richese/classic entries compose Nexus with Skills or no Skills, optional Homeworld/Discovery/Tech3+/Advanced Strongholds2+ and one diff --git a/docs/DISCOVERY_PROTOTYPE.md b/docs/DISCOVERY_PROTOTYPE.md index ecf38a18..37299a89 100644 --- a/docs/DISCOVERY_PROTOTYPE.md +++ b/docs/DISCOVERY_PROTOTYPE.md @@ -5,6 +5,25 @@ partial rules coverage. It connects physical components, real actions, private controls, AI choices and saved continuation. Normal public Discovery and Advanced start gates, and the publication gate, remain closed. +## Mixed E1/E2 and paired E3 Nexus — 7 October 2026 + +Original native Nx/Discovery now admits both selected Ix+CHOAM families +with union47, or both Ecaz+Moritani with ecaz33/classics, optionally all14 +Skills/Homeworlds/Tech3+/Advanced Strongholds2+. Discovery7/8, original +parent/nested force sources, actual Maker votes/both-pile progression and +alliance-change closing deals remain. Ordinary visitors are not native +world forces, and held Stronghold effects do not migrate into nested sites. + +31 original physical programmes plus two Loyalty-stock regressions, +final489/489 checks across42 files and qualified258/288 original games +span three frozen trees and preserved continuations. All30 remaining +source-guard traces are read. Human original supply produces Cistern and +Testing Station without a chosen token face; actual all-four Maker votes +and both-pile native card draws are observed. No human free/nested entry +or winning Suk is invented where its traversal missed that opportunity. +[Canonical precise source/game/human evidence](NEXUS_CARD_RULES.md#mixed-e1e2-and-paired-e3-nexus--7-october-2026). +Complete combinations, pending printed rulings and public starts remain open. + ## Single-native E1/E2 Nexus composition — 7 October 2026 Original single-native Ixians OR Tleilaxu/Ix47, or CHOAM OR diff --git a/docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md b/docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md index 90ed96bc..314bcac9 100644 --- a/docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md +++ b/docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md @@ -6,6 +6,24 @@ disabled.** It does not certify Homeworld battles, occupation, card effects or complete Advanced/expansion games. The earlier component checkpoint remains in [HOMEWORLD_IMPLEMENTATION.md](HOMEWORLD_IMPLEMENTATION.md). +## Mixed E1/E2 and paired E3 Nexus — 7 October 2026 + +Original Homeworld/Nexus now composes natives from both selected Ix+CHOAM +families/union47 or both Ecaz+Moritani/ecaz33 with classics, optionally +all14 Skills/Discovery7/8/Tech3+/Advanced Strongholds2+. Original typed +native/visitor custody, source-local winning rescue/residual substitution, +shared Duke, native assassination and printed payer ledgers are reused. + +312 original admissions,31 programmes plus two Loyalty-stock regressions, +final489/489 checks across42 files and qualified258/288 original games +retain30 printed source stops. Actual390px mixed Ixian native2/1 shipping +and one-normal/one-Cyborg world transport charge the original four/two +spice; actual paired Ecaz native1 transport costs one, then native Cunning +takes the separate living Duke with no extra debit. Both public inspectors +show20 counters. No new return adapter, played-save conversion or +complete-mode/deployment gate is opened. +[Canonical qualified evidence](NEXUS_CARD_RULES.md#mixed-e1e2-and-paired-e3-nexus--7-october-2026). + ## Single-native E1/E2 Nexus composition — 7 October 2026 Original Homeworld/Nexus now composes exactly one Ixians OR Tleilaxu, diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 4e66f85d..7f0d2bd7 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,28 @@ # Dune implementation status +## 7 October 2026 — Mixed E1/E2 and paired E3 Nexus + +Original both-family Ix+CHOAM/union47 or both Ecaz+Moritani/ecaz33 with +classics preserves all12 Nexus, optional all14/HW/Discovery7/8/Tech3+/ +Advanced Strongholds2+ through existing initializers and consumers. +312 original fresh admissions reach setup versus four before. +New31 physical programmes plus two Loyalty-stock regressions and the +final489/489 affected checks across42 files pass with types/lint/build. +Native Ecaz assassination, omitted separate Loyalty stock and exact paired +overlap admission repair actual captured consumer gaps. Obsolete old +mixed/paired rejection rows are removed, not re-pinned. + +All64 original six-seat families finish258/288 with338,931accepted/ +339,426attempted/9,013JSON across three frozen trees and original-prefix +continuations. All receipts and30 retained source-guard traces are read. +Actual390px original native offers/training, Maker/both piles/card draws, +paid typed native/visitor transport, free Suboid plan and separate living +Duke preserve20 physical counters in3AVHJN76v765/PSDLV4FJv121. +Human Ixian loses: no winner-only rescue or unobserved nested entry claimed. +[Canonical source and runtime qualifications](NEXUS_CARD_RULES.md#mixed-e1e2-and-paired-e3-nexus--7-october-2026). +Final489/489 union, bundle and actual390px guide pass; push/exact CI follow; +complete rules, public/deployment gates and deferred assurance remain open. + ## 7 October 2026 — Single-native E1/E2 Nexus Original one-native Ixians OR Tleilaxu/Ix47, or CHOAM OR Richese/CHOAM35 diff --git a/docs/LEADER_SKILLS_RUNTIME.md b/docs/LEADER_SKILLS_RUNTIME.md index 2d19a248..31f4bae1 100644 --- a/docs/LEADER_SKILLS_RUNTIME.md +++ b/docs/LEADER_SKILLS_RUNTIME.md @@ -5,6 +5,26 @@ Normal mode starts and publication remain gated. The [source contract](LEADER_SK contains all fourteen physical cards; the [capture interpretation](LEADER_SKILLS_CAPTURE.md) records the fresh publisher/designer search requested by the user. +## Mixed E1/E2 and paired E3 Nexus — 7 October 2026 + +The original all14/Nexus entry now composes natives from both selected +Ix+CHOAM families/union47 or both Ecaz+Moritani/ecaz33 with classics and +optional original Homeworlds/Discovery7/8/Tech3+/Advanced Strongholds2+. +Native offers, ordinary discs, Auditor/shared-Duke exclusions and physical +payments/rescue/rewards remain. The exact new bare-Nexus profiles feed +the existing Bureaucrat payment consumer; two real five-spice bribes +redirect two to the bank once, not owner income or a payer refund. + +New31 physical programmes plus two separate-Loyalty stock regressions, +the final489/489 affected checks across42 files pass with types/lint/build. +Original64-family play completes258/288 across **three** frozen trees and +preserved continuations;30 source-guard captures remain. Actual original +human Ixian offer/hidden trained plan, paid typed visitor shipment and +native free Suboid support preserve20 counters. Its actual loss is not +a winner-only Suk rescue; the winning programmes prove those outcomes. +[Canonical source and runtime qualifications](NEXUS_CARD_RULES.md#mixed-e1e2-and-paired-e3-nexus--7-october-2026). +Complete rules, public starts, full AI and comprehensive assurance remain open. + ## Single-native E1/E2 Nexus composition — 7 October 2026 Original `leader-skills --nexus-cards` now admits exactly one native Ixians diff --git a/docs/MORITANI_ASSASSINATE_LEADERS.md b/docs/MORITANI_ASSASSINATE_LEADERS.md index 3de16829..3d0c8372 100644 --- a/docs/MORITANI_ASSASSINATE_LEADERS.md +++ b/docs/MORITANI_ASSASSINATE_LEADERS.md @@ -58,6 +58,20 @@ casualties cannot replay after reveal/decline. A different eligible trained disc returns its physical skill once; printed bounty and original Mentat replacement remain. This scope is not a printed restriction on other combinations. +**7 October 2026 — original paired native Nexus composition:** the exact +Ecaz/Moritani/classic profiles, without Skills or with all14 and original +optional modules, now admit **Ecaz as an opposing printed faction**. +The classic-only trigger/receipt list was a software scope limit, not +a printed prohibition. Two real original native Ecaz battle losses reject +before, then reveal a different actual held Ecaz card, kill its original +disc for printed bounty and replace it once at Mentat after the shared list +gains Ecaz. Winning-disc exclusion, game-long normal-call forfeiture, +shared/captured/Ghola and Advanced Harkonnen guards remain. A foreign +Homeworld's normal Traitor prohibition does not prohibit this distinct +native ability. The no-Skills receipt retains its earlier pause before +winner casualties; all14 retains its skill-first pause afterward. +[Canonical programme, game and human qualifications](NEXUS_CARD_RULES.md#mixed-e1e2-and-paired-e3-nexus--7-october-2026). + Every publicly qualifying loss gets the same private reveal-or-continue opportunity, regardless of the hidden eligible card. Only Moritani sees its candidate identities and blocked reason; rivals see the same decision and no private options. This diff --git a/docs/MORITANI_ENTRY_TIMING.md b/docs/MORITANI_ENTRY_TIMING.md index 25e9545c..e45c2126 100644 --- a/docs/MORITANI_ENTRY_TIMING.md +++ b/docs/MORITANI_ENTRY_TIMING.md @@ -23,6 +23,33 @@ Both optional trigger opportunities belong to the **same already committed entry This bounded path excludes competing BG fighter Intrusion, Guild income/advisor and CHOAM prevention windows, Homeworlds, Nexus, Discoveries, Leader Skills and the still-unfinished Atomics effect. Fremen relocation or Guild free-shipment Ambassador effects can create nested arrivals before the second owner; they remain declineable but cannot be activated in this overlap until their child continuation is integrated. These guards are development boundaries, not physical-game prohibitions. +## Paired native Nexus composition — 7 October 2026 + +The existing selected storm-order interpretation now also composes the +**exact original paired Ecaz/Moritani Nexus profiles**, without Skills or +with all14, optionally original Homeworlds/Discovery/Tech/Advanced Strongholds. +This extends a software envelope, not publisher authority or the pending +priority questions. Original competing live reactions, Atomics and +unsupported child-effect guards remain. + +An original six-seat Advanced Discovery/Nexus/Tech continuation reaches +turn9 after its original Intrusion decline and Guild income are finished. +BG's accompanying free counter joins **two existing fighters**, not an +exempt advisor group, in Habbanya Ridge Sietch. Both native markers qualify. +The old classic-only overlap predicate rejects the original advisor +allowance. Both exact paired profiles now reuse the same serialized +original entrant, amount, token identity and storm order. All four saved +policies finish Ecaz decline then Moritani Terror reveal: fighters2→3, +one reserve withdrawn, BG purse unchanged. No original paid shipment, +force transfer, Intrusion or income replays. + +The exact original seed20291514 capture finishes221/221 actions and five +JSON after that original prefix; its source and input SHA remain recorded +in the [canonical qualified proof](NEXUS_CARD_RULES.md#mixed-e1e2-and-paired-e3-nexus--7-october-2026). +This does not merge an advisor's separate arrival into the parent's +reaction, make advisors eligible for Ambassadors, or claim a universal +optional-module priority order. + ## Atomics and later allies The current official indexed p. 5 still says: “From this turn forward, your hand limit is reduced by 1 (as well as your ally’s)”. It does not specify how a later alliance change affects either former or new allies. Both a continuing current-ally modifier and a detonation-time recipient remain interpretations. No targeted clarification was found. [GF9 E3, p. 5](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=5) diff --git a/docs/NEXUS_CARD_RULES.md b/docs/NEXUS_CARD_RULES.md index a48af55d..ea3ff728 100644 --- a/docs/NEXUS_CARD_RULES.md +++ b/docs/NEXUS_CARD_RULES.md @@ -2,6 +2,153 @@ Original source audit, 10 September 2026, with dated bounded runtime follow-ups below. This document establishes the printed component inventory, source protocol and implementation boundaries; none lifts expansion release gates. The ordinary worm-triggered alliance Nexus already in the engine is distinct from this optional card module. +## Mixed E1/E2 and paired E3 Nexus — 7 October 2026 + +The original private Nexus/Skills entries now admit native factions from +**both** selected Ix+CHOAM families with classics, union47 Treachery Cards +and the separate native Richese cache10, or **both** Ecaz+Moritani with +classics/ecaz33. All12 Nexus remain, with all14 Skills only when selected. +Basic/Advanced2–6 may retain Homeworlds, Discovery7/8, Tech3+ and Advanced +Strongholds2+; both industry/Strongholds requires Advanced3+. Paired E3 +retains the original Advanced Harkonnen capture exclusion, but does not +invent a Basic Harkonnen restriction. + +Existing setup, first cards/offers, ordinary training, native HMS/Face +Dancers/Auditor/cache and E3 Ambassadors/Terror/shared Duke are reused. +Fresh paired E3 marks original Arrakis Occupy and Advanced assassination; +the shared Duke never becomes a training disc. Original same-source +rescue/exchange, paid support, holder-only effects and cleanup/winner +Tech/Face Dance timing remain. No initializer, shipment/return adapter, +public start or played-save conversion is added. + +Original312 fresh admissions reach real setup, versus four before; +Ixian all14 still begins with its real pre-training offer. The31 new +physical programmes plus two physical-loyalty regressions retain eight +original assassination quotes and the original overlap cases. +The final affected union passes489/489 across42 files, with types/lint +and the compiled application passing. +Representative programmes cover bare-Nexus Smuggler, +Basic mixed families, original native/visitor/nested Suk and residual +Cyborg exchange, original matching Face Dance, signed Richese5+3 and +delayed CHOAM actual support, native coalition/selected Warmaster, +quiet Duke, assassination/replacement, supply Cunning/Terror and Basic +odd-force rejection. + +One real original consumer gap is repaired: Moritani's trigger/receipt +opponent list retained only classic factions after paired Ecaz admission. +The printed ability permits a different ordinary opposing-faction disc +once per faction; this was a software scope limit, not a printed Ecaz ban. +Actual original no-Skills/all14 losses to native Ecaz reject assassination +before, then kill a different original disc for printed bounty and one +actual Mentat replacement after the shared list gains Ecaz. The unused +winning-disc, normal-call forfeiture, shared/captured/Ghola and Advanced +Harkonnen boundaries are not weakened. +[Canonical native source](MORITANI_ASSASSINATE_LEADERS.md#authority-and-advanced-duration-cutover). + +Foreign Homeworlds exclude a visitor's **normal** Traitor call, not this +separate native assassination. The original no-Skills receipt pauses +before winner casualties; all14 finishes casualties/Suk first. Both retain +and resume the original winner suffix. Quiet face-up normal Suk and +behind-shield selected training are actual distinct legal choices, not +interchangeable prompt defaults. Sietch Tabr victory income is the opposing +dial's floor; Tuek's Worthless income is a different effect. + +An original bare mixed all14 Bureaucrat runtime smoke pays two actual +five-spice bribes: payer10→0, recipient shield escrow0→8, two redirected +to the bank once this phase, owner purse unchanged. No payer rebate, +invented native income or duplicate phase use is introduced. + +The64 runner families are `mixed-nexus`, `mixed-discovery-nexus`, +`mixed-homeworld-nexus`, `mixed-homeworld-nexus-discovery` and their +`paired-e3-` counterparts, each optionally `-skills`, then `-tech`, +`-stronghold` or `-stronghold-tech`. Stable17400–23700 bands leave prior +names/seeds unchanged. Actual family/deck/full-roster names distinguish +all four cross-native pairs and the four-native mixed roster; paired E3 +uses its original paired initializer. Resume requires exact components +and original Ecaz/Moritani marks. + +Original64 families run288 six-seat Basic/Advanced samples at seed20270007. +The first unchanged `95582a34d0fe3f006952783d0636129455d94275208aa7aaad14e176e637e0c0` +tree completes226, with313,954 accepted/314,143 attempted/8,351 JSON. +All64 before/after receipts are read. Thirty-two paired Advanced samples +stop at original setup because the CLI's fixed physical stock check omitted +Ecaz's separately held Loyalty card, not because the engine lost that card. +The verifier now counts the actual non-null card; two original no-Skills/ +all14 regressions reject its deletion or duplication instead of weakening +the stock assertion. + +All32 original post-setup captures resume at their **same scenario seeds**, +without replaying their setup. The unchanged +`df0acb2918b144f5bc92992ca2ee77e436ee4848ec8d117089c154e5993dc590` +tree completes31 with24,756/25,062 actions and657 JSON. Every receipt and +input SHA is read. The remaining actual turn9 advisor response joins two +existing BG fighters and then reaches both native markers; its classic-only +overlap predicate omitted these exact paired Nexus envelopes. It is not an +exempt spiritual-advisor arrival. The existing selected storm-order policy +now composes these two original paired profiles, while competing reaction +and child-effect guards remain. +[Exact interpretation and limits](MORITANI_ENTRY_TIMING.md#paired-native-nexus-composition--7-october-2026). + +All four original policies settle that actual capture into three fighters, +with one reserve counter withdrawn and the BG purse unchanged: original +Ecaz decline, then native Terror reveal, no repeated shipment or payment. +Its original seed20291514 continuation finishes221/221/five JSON on unchanged +`12f02271f9c05ce520158d1ab3969a5e88c56f1e741d21dd687950ad1ac0ca1e`, +input SHA `f018f39e2bc9d0c59a228f85e4f265fc44df39ee894f9b7d550db23870adc271`. +The combined258/288 with338,931 accepted/339,426 attempted/9,013 JSON is +**three frozen trees and preserved-prefix continuations**, not288 green +uninterrupted games or a final-document-tree certificate. All30 remaining +actual stop traces are read:24 low-Tupile opening-income, two exhausted +Richese caches, one first-high Homeworld revival deployment and three Basic +odd-force Occupy guards. No guard is bypassed or rerun for confirmation. + +Original human **3AVHJN76 v765**, Ixians/CHOAM/Guild/Fremen, preserves HW/ +Tech/Stronghold selection through the original all14/Nexus/Discovery entry. +The actual four-card Ix offer supplies Shield; its actual Swordmaster/Suk +offer supplies Suk on C’tair. No trainer, hand, army or outcome is staged. +The first two overly restrictive traversal predicates miss their windows +and actual play continues, not a reset or clock rewrite. Its first native +draw occurs at the real turn5 worm after a real Guild/Fremen alliance change. +The original entirely undealt all12 packet is permuted only then. + +Native typed two-force/one-Cyborg Gara arrival pays four,10→6, reserves14→12 +and Cyborg reserves4→3. Later actual one-normal/one-Cyborg visitor transport +to Junction pays two,7→5, reserves12→10/Cyborg3→2. In the original selected +Battle, hidden C’tair's native Cunning spends Ix Nexus with purse6 unchanged; +the actual one-force/zero-support plan keeps the free Suboid support. +Guild's funded native plan wins, pays its original two support, and native +CHOAM receives its printed one spice. The visitor's normal Traitor attempt +rejects before a legal Guild no-call. **Suk is a winner-only dialed-casualty +rescue:** this actual Ixian loss grants no rescue or substitution; the31 +programmes prove winning native/visitor/nested rescue and residual exchange. +The final physical20 are10 native, six HMS and four Tanks; seven Cyborgs +remain two native, three HMS and two Tanks. + +Original human **PSDLV4FJ v121**, both Ecaz/Moritani with Guild/Emperor, +uses the existing no-Skills paired Nexus initializer with the same modules. +Original five-disc Ecaz/Loyalty, native placements and Advanced assassination +marks remain; Moritani's actual offer contains no Ecaz card and is not +rewritten. Actual all-four Maker votes and both piles precede a real +Guild/Emperor alliance change and the first wholly undealt packet. Native +Ecaz ships one original reserve to Junction for one spice,12→11/res14→13, +then human quiet Battle Cunning spends Ecaz Nexus and takes the one living, +untrained Duke for this turn without another debit. The physical20 are13 +native, six Imperial Basin and one Junction visitor. Actual390px public +force inspectors show20 in both rooms; the Duke inspector shows strength6, +Ecaz control and one shared disc with no Traitor Card. Both viewport and +document are390px. No human winning Suk, nested entry or Moritani reveal is +invented from these observations. + +Real CLI two-seat mixed Skills/Tech and Basic paired Discovery/Stronghold +requests reject with exit1 **before** creating either output directory. +The final489/489 affected union, types/lint and compiled bundle pass. +The actual compiled guide shows current source/roster limits and qualified +31+2/489/258-of-288 evidence at viewport/document390px; same original +765/121 native states are observed after the owned-only code cutover. +Checkpoint push and exact terminal CI receipt follow. Full rules, +recovery/privacy/custody assurance, strategic AI/calibration and protected +deployment remain open. + ## Single-native E1/E2 Nexus composition — 7 October 2026 The original private `nexus` and `leader-skills --nexus-cards` entries now diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index f0d3a0f5..fbc5281d 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -301,7 +301,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP | Truthtrance hand categories | Count current physical hand custody by explicitly named primary role; alternate battle slots and faction powers do not reclassify cards. Only an aggregate answer is disclosed. | [Hand inventory](TRUTHTRANCE_HAND_INVENTORY.md) | | Moritani Advanced assassination preview | After an eligible battle loss, a different opposing-faction Traitor Card may be revealed even if its leader is already dead (zero bounty). Face-up set-aside and one private replacement occur during Mentat; one use per faction. Normal-call duration, uniform hidden-eligibility UX and exceptional combinations remain explicit. | [Runtime/source contract](MORITANI_ASSASSINATE_LEADERS.md) | | Moritani Extortion at Mentat | The user selected normal Terror placement/relocation first, including any Karama response; only then collect the reserved five bank spice and approach other players in storm order for a possible three-spice payment. Payment removes the token; all declines recover it with rotated hidden supply IDs. The relative placement timing is a user-selected interpretation, not a publisher ruling. | [Publisher sequence and selected timing](MORITANI_EXTORTION_RULES.md#mentat-ordering-selected-interpretation-not-a-publisher-ruling) | -| Ecaz Ambassador / Moritani Terror shared entry | In bounded classic plus Ecaz/Moritani Basic/Advanced games, both originally eligible owners resolve their optional reactions in current storm order; the committed entry, payment and counters are not replayed. Later alliance changes do not erase the original trigger, while physical effect prerequisites are rechecked. This is an explicit provisional table interpretation because no publisher priority ruling was found. BG Intrusion, child arrivals, Atomics and optional modules remain guarded. | [Primary-source gap and selected policy](MORITANI_ENTRY_TIMING.md#selected-bounded-overlap-order--29-september-2026), [movement recovery](DEFERRED_MOVEMENT_ARRIVAL.md#bounded-storm-order-continuation--29-september-2026) | +| Ecaz Ambassador / Moritani Terror shared entry | In bounded classic plus Ecaz/Moritani Basic/Advanced games and the exact original paired native Nexus envelopes, both originally eligible owners resolve optional reactions in current storm order; committed entry, payment and counters do not replay. Later alliances do not erase the original trigger; physical effect prerequisites are rechecked. This remains an explicit provisional interpretation, not a located publisher priority ruling. An original accompanying counter joining existing BG fighters creates its own qualifying entry only after earlier Intrusion/income finish; exempt advisors, competing live reactions, unsupported children and Atomics retain guards. | [Primary-source gap and selected policy](MORITANI_ENTRY_TIMING.md#selected-bounded-overlap-order--29-september-2026), [exact paired composition](MORITANI_ENTRY_TIMING.md#paired-native-nexus-composition--7-october-2026), [movement recovery](DEFERRED_MOVEMENT_ARRIVAL.md#bounded-storm-order-continuation--29-september-2026) | | Fremen/Ecaz final-turn victory | Reciprocal allied Ecaz and Fremen with fighters in Sietch Tabr do not block the Fremen special victory. Ecaz alone/non-allied or a third faction still blocks. The Habbanya extension remains unsupported; ordinary winner/prediction and fallback order are preserved. | [Explicit FAQ and runtime](ECAZ_VICTORY_RULES.md#fremen-final-turn-runtime-follow-up) | | Truthtrance recorded knowledge | Questions compare an already recorded BG prediction, active submitted storm dial or actually known native Fremen forecast. Missing knowledge is unknown; only the aggregate answer is published. No future promise or unknown deck peek is created. | [Connected predicates and evidence](TRUTHTRANCE_KNOWLEDGE.md) | | Nexus lifecycle | Twelve physical cards, phase draw/replacement and alliance forfeiture; native Cunning, absent-faction Secret Ally and Betrayal have separate effects. No blanket Karama immunity. | [Common source contract](NEXUS_CARD_RULES.md), [lifecycle runtime](NEXUS_CARD_RUNTIME.md) | diff --git a/game/bureaucrat-payment.ts b/game/bureaucrat-payment.ts index b5af02cd..d08a51a1 100644 --- a/game/bureaucrat-payment.ts +++ b/game/bureaucrat-payment.ts @@ -1,4 +1,4 @@ -import { homeworldLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, noOtherLeaderSkillModules, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile, type LeaderSkillProfile } from './leader-skill-profile'; +import { homeworldLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, mixedE1E2NexusLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, noOtherLeaderSkillModules, pairedE3NexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile, type LeaderSkillProfile } from './leader-skill-profile'; import { classicDiscoveryLeaderSkillsProfile } from './discovery-module-profile'; export type BureaucratPaymentKind = 'auction' | 'shipment' | 'bribe' | 'revival'; export type BureaucratPaymentSource = { @@ -36,7 +36,8 @@ export function bureaucratPaymentSignature(value: object): string { return JSON.stringify({ ...value, signature: undefined }); } export function bureaucratPaymentModeSupported(game: LeaderSkillProfile): boolean { - return (noOtherLeaderSkillModules(game) && + return mixedE1E2NexusLeaderSkillsProfile(game) || pairedE3NexusLeaderSkillsProfile(game) || + (noOtherLeaderSkillModules(game) && (!game.expansions.length || (game.expansions.length === 1 && game.expansions[0] === 'choam') || nativeExpansionLeaderSkillsProfile(game))) || homeworldLeaderSkillsProfile(game) || classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game) || strongholdLeaderSkillsProfile(game) || classicNexusLeaderSkillsProfile(game) || standaloneE3NexusLeaderSkillsProfile(game) || classicDiscoveryLeaderSkillsProfile(game) || nativeDiscoveryLeaderSkillsProfile(game); diff --git a/game/discovery-module-profile.ts b/game/discovery-module-profile.ts index e9a79058..4cb7ee3f 100644 --- a/game/discovery-module-profile.ts +++ b/game/discovery-module-profile.ts @@ -16,7 +16,8 @@ export function homeworldDiscoveryProfile(game: FactionModuleProfile): boolean { /** The same roster/module contract during play; live Discovery frames are expected. */ export function discoveryModeSupported(game: FactionModuleProfile): boolean { - if (game.nexusCards) return standaloneE3DiscoveryNexusProfile(game) || singleE1E2DiscoveryNexusProfile(game); + if (game.nexusCards) return standaloneE3DiscoveryNexusProfile(game) || singleE1E2DiscoveryNexusProfile(game) || + mixedE1E2DiscoveryNexusProfile(game) || pairedE3DiscoveryNexusProfile(game); if (typeof game.advanced !== 'boolean' || game.discoveryEnabled !== true || game.players.length < 2 || game.players.length > 6 || (game.techTokens && game.players.length < 3) || game.expansions.length > 2 || @@ -181,3 +182,57 @@ export function singleE1E2DiscoveryNexusProfile(game: FactionModuleProfile): boo } return native === 1; } + +/** Original natives from both selected E1/E2 families with Nexus/Discovery and no Skills. */ +export function mixedE1E2DiscoveryNexusProfile(game: FactionModuleProfile): boolean { + if (typeof game.advanced !== 'boolean' || game.discoveryEnabled !== true || + !game.nexusCards || game.expansions.length !== 2 || + !game.expansions.includes('ix') || !game.expansions.includes('choam') || + game.players.length < 2 || game.players.length > 6 || + (game.techTokens && game.players.length < 3) || + (game.strongholdCards && !game.advanced) || game.leaderSkills || + game.ecazTreachery || game.semutaPreview || game.advancedPreview || game.kullPreview || + game.nexusKullPreview || game.guildBetrayalPreview || game.richeseBetrayalPreview || + game.nexusIxianReplacementPreview || game.nexusIxianBetrayalPreview || + game.nexusHarkonnenBetrayalPreview) return false; + let ix = false, choam = false; + for (let i = 0; i < game.players.length; i++) { + const player = game.players[i]; + for (let j = 0; j < i; j++) if (player.faction === game.players[j].faction) return false; + let family: string | undefined; + for (const faction of FACTIONS) + if (faction.id === player.faction) { family = faction.expansion; break; } + if (family !== 'base' && family !== 'ix' && family !== 'choam') return false; + if (family === 'ix') ix = true; + if (family === 'choam') choam = true; + } + return ix && choam; +} + +/** Both original E3 natives with Nexus/Discovery and no Skills; Advanced capture exclusion remains. */ +export function pairedE3DiscoveryNexusProfile(game: FactionModuleProfile): boolean { + if (typeof game.advanced !== 'boolean' || game.discoveryEnabled !== true || + !game.nexusCards || game.expansions.length !== 1 || game.expansions[0] !== 'ecaz' || + game.players.length < 2 || game.players.length > 6 || + (game.techTokens && game.players.length < 3) || + (game.strongholdCards && !game.advanced) || game.leaderSkills || + game.ecazTreachery || game.semutaPreview || game.advancedPreview || game.kullPreview || + game.nexusKullPreview || game.guildBetrayalPreview || game.richeseBetrayalPreview || + game.nexusIxianReplacementPreview || game.nexusIxianBetrayalPreview || + game.nexusHarkonnenBetrayalPreview) return false; + let ecaz = false, moritani = false; + for (let i = 0; i < game.players.length; i++) { + const player = game.players[i]; + for (let j = 0; j < i; j++) if (player.faction === game.players[j].faction) return false; + if (player.faction === 'ecaz') ecaz = true; + else if (player.faction === 'moritani') moritani = true; + else { + if (game.advanced && player.faction === 'harkonnen') return false; + let classic = false; + for (const faction of FACTIONS) + if (faction.id === player.faction) { classic = faction.expansion === 'base'; break; } + if (!classic) return false; + } + } + return ecaz && moritani; +} diff --git a/game/engine.ts b/game/engine.ts index d8e0cfb9..aa093251 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1,7 +1,7 @@ -import { classicNexusModulesProfile, pairedNexusModulesProfile, singleE1E2NexusProfile, standaloneE3NexusProfile } from './nexus-module-profile'; +import { classicNexusModulesProfile, mixedE1E2NexusProfile, pairedE3NexusProfile, pairedNexusModulesProfile, singleE1E2NexusProfile, standaloneE3NexusProfile } from './nexus-module-profile'; import { nativeFactionTechProfile } from './faction-module-profile'; -import { classicDiscoveryLeaderSkillsProfile, classicDiscoveryNexusProfile, homeworldDiscoveryProfile, discoveryModeSupported, discoveryModuleProfile, pairedDiscoveryNexusProfile, singleE1E2DiscoveryNexusProfile, standaloneE3DiscoveryNexusProfile } from './discovery-module-profile'; -import { advancedMoritaniLeaderSkillsProfile, advancedNativeLeaderSkillsProfile, homeworldLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, pairedNexusLeaderSkillsProfile, singleE1E2NexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile } from './leader-skill-profile'; +import { classicDiscoveryLeaderSkillsProfile, classicDiscoveryNexusProfile, homeworldDiscoveryProfile, discoveryModeSupported, discoveryModuleProfile, mixedE1E2DiscoveryNexusProfile, pairedDiscoveryNexusProfile, pairedE3DiscoveryNexusProfile, singleE1E2DiscoveryNexusProfile, standaloneE3DiscoveryNexusProfile } from './discovery-module-profile'; +import { advancedMoritaniLeaderSkillsProfile, advancedNativeLeaderSkillsProfile, homeworldLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, mixedE1E2NexusLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, pairedE3NexusLeaderSkillsProfile, pairedNexusLeaderSkillsProfile, singleE1E2NexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile } from './leader-skill-profile'; import { bribeTimingBlock, maximumBribe, type BribeOptions } from './bribe-options'; import { quoteSpicePlacement, stormExposesTerritory, stormSectorAfter, wormConsumesForces } from './disaster-rules'; import { isStormCardDistance, type StormCardComponent } from './storm-cards'; @@ -7612,7 +7612,7 @@ function e3StrongholdFactionProfile(g: Game): boolean { return native !== null && (native !== 'moritani' || !byFaction(g, 'harkonnen')); } function moritaniAssassinateModeSupported(g: Game) { - if (standaloneE3NexusProfile(g)) + if (standaloneE3NexusProfile(g) || pairedE3NexusProfile(g) || pairedE3NexusLeaderSkillsProfile(g)) return g.advanced && g.players.some(player => player.faction === 'moritani'); if (g.discoveryEnabled || g.discoveries) return g.advanced && !!g.discoveries && @@ -8568,24 +8568,24 @@ export function initializeHomeworldOccupationGameForAudit(state: Game): Game { if (byFaction(g, 'ecaz')) g.homeworldEcazVictoryPreview = true; return g; } -/** Original classic or standalone E3 Nexus setup; public starts remain gated. */ +/** Original classic and supported native Nexus setup; public starts remain gated. */ export function initializeNexusGameForAudit(state: Game): Game { requireRule(!!state.nexusCards && state.nexusCards.cards === null && state.nexusCards.phase === null, 'Enable Nexus cards in a fresh audit lobby first.'); const standalone = standaloneE3NexusProfile(state); - const single = singleE1E2NexusProfile(state); + const pairedE3 = pairedE3NexusProfile(state); + const native = standalone || pairedE3 || singleE1E2NexusProfile(state) || mixedE1E2NexusProfile(state); + const discovery = classicDiscoveryNexusProfile(state) || standaloneE3DiscoveryNexusProfile(state) || + singleE1E2DiscoveryNexusProfile(state) || mixedE1E2DiscoveryNexusProfile(state) || pairedE3DiscoveryNexusProfile(state); if (state.discoveryEnabled) { - requireRule(classicDiscoveryNexusProfile(state) || standaloneE3DiscoveryNexusProfile(state) || - singleE1E2DiscoveryNexusProfile(state), - 'Discovery Nexus requires a fresh classic, single E1/E2 native or standalone E3 lobby with original optional modules.'); + requireRule(discovery, + 'Discovery Nexus requires a fresh supported original classic or native roster and module envelope.'); requireFreshBaseRuntime(state); requireFreshFactionInventory(state); } - const g = initializeSetupGameForAudit(state, !!state.homeworlds, true, false, - classicDiscoveryNexusProfile(state) || standaloneE3DiscoveryNexusProfile(state) || singleE1E2DiscoveryNexusProfile(state), - false, false, standalone || single); - if (standalone && byFaction(g, 'ecaz')) g.ecazOccupyPreview = true; - if (standalone && g.advanced && byFaction(g, 'moritani')) initializeMoritaniAssassinateState(g); + const g = initializeSetupGameForAudit(state, !!state.homeworlds, true, false, discovery, false, false, native); + if ((standalone || pairedE3) && byFaction(g, 'ecaz')) g.ecazOccupyPreview = true; + if ((standalone || pairedE3) && g.advanced && byFaction(g, 'moritani')) initializeMoritaniAssassinateState(g); return g; } /** Fresh audit admission only; ordinary games and public starts never opt in. */ @@ -9334,18 +9334,25 @@ export function initializePairedNexusGameForAudit(state: Game): Game { state.players.some(player => player.faction === 'ixians') && state.players.some(player => player.faction === 'tleilaxu'))), 'The paired Nexus sample requires both selected expansion factions and their deck.'); - const discovery = state.discoveryEnabled === true && pairedDiscoveryNexusProfile(state); + const pairedE3 = expansion === 'ecaz' && pairedE3NexusProfile(state); + requireRule(expansion !== 'ecaz' || pairedE3, + 'Paired E3 Nexus retains its original Advanced Harkonnen capture exclusion.'); + const discovery = state.discoveryEnabled === true && + (pairedDiscoveryNexusProfile(state) || pairedE3DiscoveryNexusProfile(state)); requireRule(!!state.nexusCards && state.nexusCards.cards === null && state.nexusCards.phase === null && - (!state.homeworlds || expansion === 'ix' || expansion === 'choam') && + (!state.homeworlds || expansion === 'ix' || expansion === 'choam' || pairedE3) && !state.leaderSkills && !state.ecazTreachery && (!state.discoveryEnabled || discovery) && - ((!state.techTokens && !state.strongholdCards) || discovery || pairedNexusModulesProfile(state)), - 'Enable Nexus Cards with supported original paired E1/E2 Homeworlds, Discovery, Tech or Advanced Stronghold components.'); + ((!state.techTokens && !state.strongholdCards) || discovery || pairedNexusModulesProfile(state) || pairedE3), + 'Enable Nexus Cards with supported original paired Homeworlds, Discovery, Tech or Advanced Stronghold components.'); if (state.discoveryEnabled) { requireFreshBaseRuntime(state); requireFreshFactionInventory(state); } - return initializeSetupGameForAudit(state, !!state.homeworlds, true, false, discovery, false, false, true); + const g = initializeSetupGameForAudit(state, !!state.homeworlds, true, false, discovery, false, false, true); + if (pairedE3) g.ecazOccupyPreview = true; + if (pairedE3 && g.advanced) initializeMoritaniAssassinateState(g); + return g; } /** Genuine opt-in auction source interruption; ordinary/public starts stay gated. */ @@ -9402,9 +9409,9 @@ export function initializeFactionExpansionsGameForAudit(state: Game): Game { return initialized; } function ecazOccupyCompositionSupported(g: Game): boolean { - if (g.ecazOccupyPreview === true && standaloneE3NexusProfile(g) && + if (g.ecazOccupyPreview === true && (standaloneE3NexusProfile(g) || pairedE3NexusProfile(g)) && g.players.some(player => player.faction === 'ecaz')) return true; - if (g.leaderSkills && standaloneE3NexusLeaderSkillsProfile(g) && + if (g.leaderSkills && (standaloneE3NexusLeaderSkillsProfile(g) || pairedE3NexusLeaderSkillsProfile(g)) && g.players.some(p => p.faction === 'ecaz')) return true; if (g.ecazOccupyPreview === true && g.discoveries && discoveryModeSupported(g) && g.players.some(p => p.faction === 'ecaz')) return true; @@ -9566,12 +9573,14 @@ export function initializeLeaderSkillsGameForAudit(state: Game): Game { const g = structuredClone(state); g.leaderSkills = createLeaderSkills(random); const classicNexus = classicNexusLeaderSkillsProfile(g); + const pairedE3 = pairedE3NexusLeaderSkillsProfile(g); const initialized = initializeSetupGameForAudit(g, homeworlds, classicNexus || pairedNexusLeaderSkillsProfile(g) || - singleE1E2NexusLeaderSkillsProfile(g) || standaloneE3NexusLeaderSkillsProfile(g), false, + singleE1E2NexusLeaderSkillsProfile(g) || mixedE1E2NexusLeaderSkillsProfile(g) || + standaloneE3NexusLeaderSkillsProfile(g) || pairedE3, false, classicDiscoveryLeaderSkillsProfile(g) || nativeDiscoveryLeaderSkillsProfile(g) || (g.discoveryEnabled === true && classicNexus), true, g.expansions.length === 1 && g.expansions[0] === 'choam', nativeExpansionLeaderSkillsProfile(g)); - if (advancedMoritaniLeaderSkillsProfile(initialized)) initializeMoritaniAssassinateState(initialized); - if ((homeworlds || nativeDiscoveryLeaderSkillsProfile(initialized)) && byFaction(initialized, 'ecaz')) initialized.ecazOccupyPreview = true; + if (advancedMoritaniLeaderSkillsProfile(initialized) || (pairedE3 && initialized.advanced)) initializeMoritaniAssassinateState(initialized); + if ((homeworlds || nativeDiscoveryLeaderSkillsProfile(initialized) || pairedE3) && byFaction(initialized, 'ecaz')) initialized.ecazOccupyPreview = true; return initialized; } /** Fresh all-fourteen-card classic or supported native skills; no public income toggle. */ @@ -9611,11 +9620,13 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f const nativeTech = factions && !leaderSkills && nativeFactionTechProfile(g); const discoveryComposition = discovery && (discoveryModuleProfile(g) || classicDiscoveryNexusProfile(g) || pairedDiscoveryNexusProfile(g) || + mixedE1E2DiscoveryNexusProfile(g) || pairedE3DiscoveryNexusProfile(g) || (leaderSkills && (classicDiscoveryLeaderSkillsProfile(g) || nativeDiscoveryLeaderSkillsProfile(g) || classicNexusLeaderSkillsProfile(g)))); const nexusModules = nexus && (classicNexusModulesProfile(g) || pairedNexusModulesProfile(g) || - singleE1E2NexusProfile(g) || standaloneE3NexusProfile(g) || + singleE1E2NexusProfile(g) || mixedE1E2NexusProfile(g) || pairedE3NexusProfile(g) || standaloneE3NexusProfile(g) || (leaderSkills && (classicNexusLeaderSkillsProfile(g) || pairedNexusLeaderSkillsProfile(g) || - singleE1E2NexusLeaderSkillsProfile(g) || standaloneE3NexusLeaderSkillsProfile(g)))); + singleE1E2NexusLeaderSkillsProfile(g) || mixedE1E2NexusLeaderSkillsProfile(g) || + pairedE3NexusLeaderSkillsProfile(g) || standaloneE3NexusLeaderSkillsProfile(g)))); requireRule(!g.techTokens || ((skillTech || nativeTech || nexusModules || discoveryComposition) && JSON.stringify(g.techTokens) === JSON.stringify(createTechTokens())), 'Tech Tokens require a fresh supported three-through-six-seat lobby with unused tokens.'); @@ -14699,13 +14710,14 @@ function ambassadorEffectBlock( return null; } function arrivalOverlapModeSupported(g: Game): boolean { - return !!byFaction(g, 'ecaz') && !!byFaction(g, 'moritani') && + return pairedE3NexusLeaderSkillsProfile(g) || pairedE3NexusProfile(g) || + (!!byFaction(g, 'ecaz') && !!byFaction(g, 'moritani') && g.players.every(player => CLASSIC_FACTIONS[player.faction] === true || player.faction === 'ecaz' || player.faction === 'moritani') && g.expansions.every(expansion => expansion === 'ecaz') && !g.homeworlds && !g.nexusCards && !g.leaderSkills && !g.discoveryEnabled && !g.techTokens && !g.strongholdCards && - !g.ecazTreachery; + !g.ecazTreachery); } function arrivalOverlapIntegrity(g: Game) { const record = g.pendingArrivalOverlap; @@ -25759,6 +25771,8 @@ const RICHESE_CUNNING_ROSTER: Partial> = { beneGesserit: true, guild: true, richese: true, choam: true, }; function richeseCunningModeSupported(g: Game) { + const mixed = mixedE1E2NexusProfile(g) || (!!g.leaderSkills && mixedE1E2NexusLeaderSkillsProfile(g)); + if (mixed) return !g.sandtrout; const skills = !!g.leaderSkills && (pairedNexusLeaderSkillsProfile(g) || singleE1E2NexusLeaderSkillsProfile(g)); return pairedDiscoveryNexusProfile(g) || singleE1E2DiscoveryNexusProfile(g) || (!!g.homeworlds && (skills || pairedNexusModulesProfile(g) || singleE1E2NexusProfile(g))) || diff --git a/game/leader-skill-profile.ts b/game/leader-skill-profile.ts index 00935386..df8fde5b 100644 --- a/game/leader-skill-profile.ts +++ b/game/leader-skill-profile.ts @@ -127,6 +127,46 @@ export function standaloneE3NexusLeaderSkillsProfile(game: LeaderSkillProfile): (native === 'moritani' || !game.moritaniAssassinatePreview); } +/** Original E1/E2 natives from both selected families with classics and Nexus. */ +export function mixedE1E2NexusLeaderSkillsProfile(game: LeaderSkillProfile): boolean { + if (game.expansions.length !== 2 || !game.expansions.includes('ix') || + !game.expansions.includes('choam') || !nexusLeaderSkillModulesSupported(game, false, true)) return false; + const players = game.players!; + let ix = false, choam = false; + for (let i = 0; i < players.length; i++) { + for (let j = 0; j < i; j++) if (players[j].faction === players[i].faction) return false; + let family: string | undefined; + for (const faction of FACTIONS) + if (faction.id === players[i].faction) { family = faction.expansion; break; } + if (family !== 'base' && family !== 'ix' && family !== 'choam') return false; + if (family === 'ix') ix = true; + if (family === 'choam') choam = true; + } + return ix && choam; +} + +/** Both original E3 natives and classic opponents; the shared Duke is never a training disc. */ +export function pairedE3NexusLeaderSkillsProfile(game: LeaderSkillProfile): boolean { + if (game.expansions.length !== 1 || game.expansions[0] !== 'ecaz' || + !nexusLeaderSkillModulesSupported(game, game.advanced === true, true)) return false; + const players = game.players!; + let ecaz = false, moritani = false; + for (let i = 0; i < players.length; i++) { + for (let j = 0; j < i; j++) if (players[j].faction === players[i].faction) return false; + const player = players[i]; + if (player.faction === 'ecaz') ecaz = true; + else if (player.faction === 'moritani') moritani = true; + else { + if (game.advanced && player.faction === 'harkonnen') return false; + let classic = false; + for (const faction of FACTIONS) + if (faction.id === player.faction) { classic = faction.expansion === 'base'; break; } + if (!classic) return false; + } + } + return ecaz && moritani; +} + /** Discovery keeps original native roster predicates; only this module envelope * differs. Live token/entry/Great Maker frames are original consumers. */ function discoveryLeaderSkillModulesSupported(game: LeaderSkillProfile): boolean { @@ -150,7 +190,7 @@ function discoveryLeaderSkillModulesSupported(game: LeaderSkillProfile): boolean function nativeLeaderSkillModulesSupported(game: LeaderSkillProfile): boolean { if (game.homeworlds) return homeworldLeaderSkillModulesSupported(game); if (noOtherLeaderSkillModules(game) || pairedNexusLeaderSkillsProfile(game) || - singleE1E2NexusLeaderSkillsProfile(game)) return true; + singleE1E2NexusLeaderSkillsProfile(game) || mixedE1E2NexusLeaderSkillsProfile(game)) return true; if (game.discoveryEnabled || game.discoveries) return discoveryLeaderSkillModulesSupported(game); const players = game.players; if ((!game.techTokens && !game.strongholdCards) || !players || @@ -173,7 +213,8 @@ function nativeLeaderSkillModulesSupported(game: LeaderSkillProfile): boolean { /** Standalone E3 native predicates retain their own roster, Duke and assassination limits. */ function e3LeaderSkillModulesSupported(game: LeaderSkillProfile): boolean { if (game.homeworlds) return homeworldLeaderSkillModulesSupported(game); - if (noOtherLeaderSkillModules(game) || standaloneE3NexusLeaderSkillsProfile(game)) return true; + if (noOtherLeaderSkillModules(game) || standaloneE3NexusLeaderSkillsProfile(game) || + pairedE3NexusLeaderSkillsProfile(game)) return true; if (game.discoveryEnabled || game.discoveries) return discoveryLeaderSkillModulesSupported(game); const players = game.players; if ((!game.techTokens && !game.strongholdCards) || !players || @@ -384,7 +425,8 @@ export function advancedNativeLeaderSkillsProfile(game: LeaderSkillProfile): boo } export function nativeExpansionLeaderSkillsProfile(game: LeaderSkillProfile): boolean { - return basicExpansionLeaderSkillsProfile(game) || advancedNativeLeaderSkillsProfile(game) || + return mixedE1E2NexusLeaderSkillsProfile(game) || pairedE3NexusLeaderSkillsProfile(game) || + basicExpansionLeaderSkillsProfile(game) || advancedNativeLeaderSkillsProfile(game) || advancedMoritaniLeaderSkillsProfile(game) || nativeEcazLeaderSkillsProfile(game); } @@ -405,6 +447,7 @@ function homeworldLeaderSkillModulesSupported(game: LeaderSkillProfile): boolean !players || players.length < 2 || players.length > 6 || (game.nexusCards && !classicNexusLeaderSkillsProfile(game) && !pairedNexusLeaderSkillsProfile(game) && !singleE1E2NexusLeaderSkillsProfile(game) && + !mixedE1E2NexusLeaderSkillsProfile(game) && !pairedE3NexusLeaderSkillsProfile(game) && !standaloneE3NexusLeaderSkillsProfile(game)) || (game.discoveryEnabled !== true && (game.discoveryEnabled || game.discoveries || game.discoveryStash || game.greatMaker)) || (game.techTokens && players.length < 3) || (game.strongholdCards && !game.advanced) || @@ -442,6 +485,7 @@ export function ordinaryLeaderSkillModeSupported(game: LeaderSkillProfile): bool classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game) || strongholdLeaderSkillsProfile(game) || classicNexusLeaderSkillsProfile(game) || singleE1E2NexusLeaderSkillsProfile(game) || + mixedE1E2NexusLeaderSkillsProfile(game) || pairedE3NexusLeaderSkillsProfile(game) || pairedNexusLeaderSkillsProfile(game) || standaloneE3NexusLeaderSkillsProfile(game) || classicDiscoveryLeaderSkillsProfile(game) || nativeDiscoveryLeaderSkillsProfile(game) || homeworldLeaderSkillsProfile(game); diff --git a/game/moritani-assassinate.ts b/game/moritani-assassinate.ts index f00c64f6..5fb142e3 100644 --- a/game/moritani-assassinate.ts +++ b/game/moritani-assassinate.ts @@ -64,6 +64,7 @@ const opponents: readonly FactionId[] = [ 'guild', 'emperor', 'fremen', + 'ecaz', ]; const identifier = (value: unknown): value is string => typeof value === 'string' && diff --git a/game/nexus-ecaz-duke.ts b/game/nexus-ecaz-duke.ts index 16f75eaa..e917aec8 100644 --- a/game/nexus-ecaz-duke.ts +++ b/game/nexus-ecaz-duke.ts @@ -3,8 +3,8 @@ import type { Game } from './engine'; import { acquireDuke, DUKE_VIDAL_ID, type DukeSource } from './duke-vidal'; import { nexusCardMode } from './nexus-cards'; import { nexusCleanPlayBlocked } from './nexus-play-boundary'; -import { standaloneE3NexusLeaderSkillsProfile } from './leader-skill-profile'; -import { standaloneE3NexusProfile } from './nexus-module-profile'; +import { pairedE3NexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile } from './leader-skill-profile'; +import { pairedE3NexusProfile, standaloneE3NexusProfile } from './nexus-module-profile'; const SUPPORTED_FACTIONS: Partial> = { ecaz: true, moritani: true, atreides: true, harkonnen: true, @@ -66,7 +66,7 @@ export function quoteNexusEcazDuke(g: Game, owner: string, automaticPending = fa if (g.advanced && g.players.some(player => player.faction === 'harkonnen')) blocked = 'Duke Vidal battle use is unavailable with Advanced Harkonnen.'; else if (!nexusEcazDukeModeSupported(g) || nexusCardMode('ecaz', holder.faction, roster) !== 'cunning') - blocked = 'Ecaz Nexus Cunning requires the paired Nexus-only roster or a fresh supported standalone Ecaz Nexus profile.'; + blocked = 'Ecaz Nexus Cunning requires a supported original paired or standalone Ecaz Nexus profile.'; else if (holder.ally) blocked = 'Use Ecaz Nexus Cunning while unallied.'; else if (g.status !== 'playing' || g.phase !== 6) blocked = 'Use Ecaz Nexus Cunning at the Battle phase boundary.'; @@ -88,10 +88,11 @@ export function quoteNexusEcazDuke(g: Game, owner: string, automaticPending = fa } export function nexusEcazDukeModeSupported(g: Game): boolean { - if (standaloneE3NexusProfile(g) && g.players.some(player => player.faction === 'ecaz') && + if ((standaloneE3NexusProfile(g) || pairedE3NexusProfile(g)) && + g.players.some(player => player.faction === 'ecaz') && !g.sandtrout && !(g.advanced && g.players.some(player => player.faction === 'harkonnen'))) return !!g.nexusCards?.cards && g.players.length >= 3; - if (g.leaderSkills && standaloneE3NexusLeaderSkillsProfile(g) && + if (g.leaderSkills && (standaloneE3NexusLeaderSkillsProfile(g) || pairedE3NexusLeaderSkillsProfile(g)) && g.players.some(player => player.faction === 'ecaz') && !g.sandtrout) return !!g.nexusCards?.cards && g.players.length >= 3; return !!g.nexusCards?.cards && g.players.length >= 3 && g.players.length <= 6 && diff --git a/game/nexus-module-profile.ts b/game/nexus-module-profile.ts index 55ca2415..71296270 100644 --- a/game/nexus-module-profile.ts +++ b/game/nexus-module-profile.ts @@ -1,6 +1,6 @@ import { FACTIONS } from './catalog'; import type { FactionModuleProfile } from './faction-module-profile'; -import { classicDiscoveryNexusProfile, pairedDiscoveryNexusProfile, singleE1E2DiscoveryNexusProfile, standaloneE3DiscoveryNexusProfile } from './discovery-module-profile'; +import { classicDiscoveryNexusProfile, mixedE1E2DiscoveryNexusProfile, pairedDiscoveryNexusProfile, pairedE3DiscoveryNexusProfile, singleE1E2DiscoveryNexusProfile, standaloneE3DiscoveryNexusProfile } from './discovery-module-profile'; /** Original classic Nexus modules, optionally Homeworlds; initializer checks undealt components. */ export function classicNexusModulesProfile(game: FactionModuleProfile): boolean { @@ -115,3 +115,60 @@ export function singleE1E2NexusProfile(game: FactionModuleProfile): boolean { } return native === 1; } + +/** Both selected E1/E2 native families with original Nexus and optional modules, without Skills. */ +export function mixedE1E2NexusProfile(game: FactionModuleProfile): boolean { + if (game.discoveryEnabled || game.discoveries) return mixedE1E2DiscoveryNexusProfile(game); + if (typeof game.advanced !== 'boolean' || !game.nexusCards || + game.expansions.length !== 2 || !game.expansions.includes('ix') || !game.expansions.includes('choam') || + game.players.length < 2 || game.players.length > 6 || + (game.techTokens && game.players.length < 3) || + (game.strongholdCards && !game.advanced) || game.leaderSkills || + game.discoveryEnabled || game.discoveries || game.discoveryStash || game.greatMaker || + game.ecazTreachery || game.semutaPreview || game.advancedPreview || + game.kullPreview || game.nexusKullPreview || game.guildBetrayalPreview || + game.richeseBetrayalPreview || game.nexusIxianReplacementPreview || + game.nexusIxianBetrayalPreview || game.nexusHarkonnenBetrayalPreview) return false; + let ix = false, choam = false; + for (let i = 0; i < game.players.length; i++) { + const player = game.players[i]; + for (let j = 0; j < i; j++) if (player.faction === game.players[j].faction) return false; + let family: string | undefined; + for (const faction of FACTIONS) + if (faction.id === player.faction) { family = faction.expansion; break; } + if (family !== 'base' && family !== 'ix' && family !== 'choam') return false; + if (family === 'ix') ix = true; + if (family === 'choam') choam = true; + } + return ix && choam; +} + +/** Both original E3 natives with classic opponents, original Nexus and optional modules, without Skills. */ +export function pairedE3NexusProfile(game: FactionModuleProfile): boolean { + if (game.discoveryEnabled || game.discoveries) return pairedE3DiscoveryNexusProfile(game); + if (typeof game.advanced !== 'boolean' || !game.nexusCards || + game.expansions.length !== 1 || game.expansions[0] !== 'ecaz' || + game.players.length < 2 || game.players.length > 6 || + (game.techTokens && game.players.length < 3) || + (game.strongholdCards && !game.advanced) || game.leaderSkills || + game.discoveryEnabled || game.discoveries || game.discoveryStash || game.greatMaker || + game.ecazTreachery || game.semutaPreview || game.advancedPreview || + game.kullPreview || game.nexusKullPreview || game.guildBetrayalPreview || + game.richeseBetrayalPreview || game.nexusIxianReplacementPreview || + game.nexusIxianBetrayalPreview || game.nexusHarkonnenBetrayalPreview) return false; + let ecaz = false, moritani = false; + for (let i = 0; i < game.players.length; i++) { + const player = game.players[i]; + for (let j = 0; j < i; j++) if (player.faction === game.players[j].faction) return false; + if (player.faction === 'ecaz') ecaz = true; + else if (player.faction === 'moritani') moritani = true; + else { + if (game.advanced && player.faction === 'harkonnen') return false; + let classic = false; + for (const faction of FACTIONS) + if (faction.id === player.faction) { classic = faction.expansion === 'base'; break; } + if (!classic) return false; + } + } + return ecaz && moritani; +} diff --git a/game/reference.ts b/game/reference.ts index a0745626..f0e00ff9 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -3820,7 +3820,7 @@ export const RULE_TOPICS: RuleTopic[] = [ ).join(' '), steps: [ 'Homeworlds are an optional module from Ecaz & Moritani, available under the official rules with Basic or Advanced play. They are separate locations outside Arrakis; they do not receive ordinary storm movement or count as ordinary strongholds.', - 'Fresh private Homeworld prototypes compose all fourteen Leader Skills, Discovery seven cards/eight tokens or both, Basic/Advanced two through six classic or supported native E1/E2 seats, or standalone Ecaz OR Moritani with classic opponents. Optional original Tech requires three or more seats; Stronghold Cards require Advanced. Classic, single or paired Ixians/Tleilaxu or CHOAM/Richese with one family deck, or standalone Ecaz OR Moritani entries may retain all12 Nexus through the existing skill or Nexus entry, with or without Skills. Original family decks and each roster/effect restriction remain: Advanced Moritani excludes Harkonnen; an otherwise legal no-Skills Ecaz/Harkonnen roster does not lift the separate Duke Cunning exclusion. Original setup/native placement, phase-end industry, source-local held cards and cleanup before mandatory winner Tech remain. Typed native Suk saves stay at the same home; external Emperor returns explicitly allocate normal/elite counters to actual native homes, an implementation interpretation rather than ordinary revival placement. Normal revival separately deposits Sardaukar at Salusa. Nested battles receive no Homeworld bonus; later Cistern income is separate from support/rescue. Mixed-deck Nexus, unsupported E3 pairs/mixed families, unrelated overlays, public starts and pending interactions stay separate.', + 'Fresh private Homeworld prototypes retain all fourteen Skills, Discovery7/8 or both in classic or supported E1/E2 and standalone Ecaz OR Moritani tables. Original Nexus entries additionally support single or paired one-family E1/E2, natives from BOTH selected Ix+CHOAM families with union47, and standalone OR paired Ecaz/Moritani/ecaz33 with classics, with or without Skills. Basic/Advanced2–6, Tech3+ and Advanced Strongholds2+ retain original setup, native placement, phase-end industry and source-local held cards. Advanced Moritani/paired E3 excludes Harkonnen; otherwise legal standalone no-Skills Ecaz/Harkonnen does not lift its separate Duke Cunning exclusion. Winning typed native Suk saves stay at that home; external Emperor returns allocate physical normal/elite counters to original homes as the existing interpretation. Normal revival separately deposits Sardaukar at Salusa. Nested battles receive no Homeworld bonus. Mixed E3/three-family Nexus, pending effects, public starts and played-save conversion remain separate.', 'Each faction uses its own card and matching world token. In Basic play the Emperor uses Kaitain alone. Advanced play adds Salusa Secundus for its Sardaukar; movement can transfer both force types between the two Emperor worlds.', 'Native reserves determine high or low population. Salusa counts Sardaukar, while the other cards count the faction’s reserves there. Reaching the high face’s minimum restores high population. Occupation also applies the low penalty.', 'Low population normally grants one additional free force revival and one additional bank spice when collecting CHOAM Charity. Salusa grants neither bonus; CHOAM’s Tupile reverses the usual pattern by granting an advantage while low and a penalty while high.', @@ -3984,104 +3984,100 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Verification', status: 'Partial', detail: - 'Single-native E1/E2 Nexus adds15 meaningful programmes; affected398/398 across35 files and types/lint pass. All32 original six-seat families finish190/192 games with208205 accepted/208213 attempted actions and5539 JSON across two frozen trees, including one repaired original Smuggler continuation. Exhausted-cache/low-Tupile captures retain source guards. Actual390px original trained Ixian offer/typed Cistern entry, Maker/both-pile draw, free Suboid support and equal nested Cyborg exchange preserve20 physical/seven Cyborgs; no-training Richese five-plus-three pair/reveal costs one spice and preserves20. Earlier classic/native/E3 evidence remains canonical. Full rules, combinations, assurance/calibration and deployed acceptance remain open.', - evidence: [ - 'tests/single-nexus-e1-runtime.test.ts', - 'tests/single-nexus-e2-runtime.test.ts', - 'tests/homeworld-classic-skills-runtime.test.ts', - 'tests/homeworld-classic-discovery-runtime.test.ts', - 'tests/homeworld-skills-discovery-runtime.test.ts', - 'tests/homeworld-optional-modules-runtime.test.ts', - 'tests/homeworld-native-e1e2-modules-runtime.test.ts', - 'tests/homeworld-native-e3-modules-runtime.test.ts', - 'tests/homeworld-classic-nexus-skills-runtime.test.ts', - 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', - 'tests/homeworld-paired-nexus-runtime.test.ts', - 'tests/homeworld-standalone-nexus-runtime.test.ts', - 'tests/e3-nexus-ecaz-runtime.test.ts', - 'tests/e3-nexus-moritani-runtime.test.ts', - 'tests/homeworld-custody.test.ts', - 'tests/homeworld-cards.test.ts', - 'tests/homeworld-payment-income.test.ts', - 'tests/homeworld-payment-income-engine.test.ts', - 'tests/homeworld-payment-income-recovery.test.ts', - 'tests/homeworld-collection.test.ts', - 'tests/homeworld-collection-engine.test.ts', - 'tests/homeworld-collection-recovery.test.ts', - 'tests/homeworld-grumman-engine.test.ts', - 'tests/homeworld-grumman-recovery.test.ts', - 'tests/homeworld-setup-engine.test.ts', - 'tests/homeworld-actions-engine.test.ts', - 'tests/homeworld-basic-counters.test.ts', - 'tests/homeworld-salusa-engine.test.ts', - 'tests/homeworld-combat.test.ts', - 'tests/homeworld-battle-resolution.test.ts', - 'tests/homeworld-battle-engine.test.ts', - 'tests/homeworld-battle-recovery.test.ts', - 'tests/homeworld-shipment-engine.test.ts', - 'tests/homeworld-shipment-recovery.test.ts', - 'tests/homeworld-shipment-controls.test.ts', - 'tests/homeworld-shipment-review.test.ts', - 'tests/guild-homeworld-shipment-engine.test.ts', - 'tests/guild-homeworld-shipment-recovery.test.ts', - 'tests/guild-homeworld-shipment-controls.test.ts', - 'tests/junction-transport.test.ts', - 'tests/junction-transport-engine.test.ts', - 'tests/junction-transport-recovery.test.ts', - 'tests/junction-transport-controls.test.ts', - 'tests/junction-transport-review.test.ts', - 'tests/homeworld-card-economy.test.ts', - 'tests/homeworld-tupile-sales-engine.test.ts', - 'tests/homeworld-tupile-sales-recovery.test.ts', - 'tests/bidding-end-engine.test.ts', - 'tests/bidding-end-controls.test.ts', - 'tests/bidding-end-recovery.test.ts', - 'tests/ecaz-poison-income.test.ts', - 'tests/ecaz-poison-income-engine.test.ts', - 'tests/homeworld-mobility.test.ts', - 'tests/homeworld-mobility-engine.test.ts', - 'tests/homeworld-mobility-recovery.test.ts', - 'tests/homeworld-spiritual-advisors.test.ts', - 'tests/homeworld-spiritual-advisors-recovery.test.ts', - 'tests/homeworld-advisor-ambassador.test.ts', - 'tests/homeworld-replacement-engine.test.ts', - 'tests/homeworld-recovery.test.ts', - 'tests/homeworld-charity-engine.test.ts', - 'tests/homeworld-revival-benefits-engine.test.ts', - 'tests/homeworld-revival-deployment.test.ts', - 'tests/homeworld-revival-return.test.ts', - 'tests/homeworld-revival-destinations.test.ts', - 'tests/homeworld-revival-deployment-engine.test.ts', - 'tests/homeworld-revival-deployment-controls.test.ts', - 'tests/homeworld-revival-deployment-recovery.test.ts', - 'tests/homeworld-victory-reinforcement.test.ts', - 'tests/caladan-reinforcement-controls.test.ts', - 'tests/homeworld-victory-reinforcement-recovery.test.ts', - 'tests/tupile-intelligence.test.ts', - 'tests/tupile-intelligence-answer.test.ts', - 'tests/tupile-intelligence-state.test.ts', - 'tests/tupile-intelligence-controls.test.ts', - 'tests/tupile-intelligence-engine.test.ts', - 'tests/tupile-intelligence-recovery.test.ts', - 'tests/homeworld-occupation-history.test.ts', - 'tests/homeworld-occupied-income.test.ts', - 'tests/homeworld-occupied-percentage.test.ts', - 'tests/homeworld-occupied-bidding.test.ts', - 'tests/homeworld-occupied-defenses.test.ts', - 'tests/homeworld-occupation-runtime.test.ts', - 'tests/homeworld-occupied-producers-engine.test.ts', - 'tests/homeworld-occupied-private-lots.test.ts', - 'tests/homeworld-occupied-tupile.test.ts', - 'tests/homeworld-occupied-tupile-runtime.test.ts', - 'tests/ecaz-homeworld-victory.test.ts', - 'tests/ecaz-homeworld-victory-runtime.test.ts', - 'tests/grumman-collection.test.ts', - 'tests/grumman-collection-engine.test.ts', - 'tests/grumman-collection-controls.test.ts', - 'tests/grumman-collection-recovery.test.ts', - 'tests/grumman-selection-integrity.test.ts', - 'tests/homeworld-benefits-recovery.test.ts', - ], + 'Mixed E1/E2 and paired E3 Nexus adds31 original physical programmes plus2 Loyalty-stock regressions. Final489/489 affected checks across42 files pass with types/lint. All64 original families finish258/288 six-seat games with338931 accepted/339426 attempted actions and9013 JSON across three frozen trees and preserved-prefix continuations. All30 remaining source-guard traces are read. Actual390px original Ixian offer/training, typed native and foreign-world payment/free Suboid plan and paired Ecaz foreign entry/living shared Duke preserve20 physical counters. Human Ixian loses, so no winning Suk rescue is claimed. Earlier qualified evidence remains canonical; full rules, assurance, calibration and deployed acceptance remain open.', + evidence: ['tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/homeworld-classic-skills-runtime.test.ts', + 'tests/homeworld-classic-discovery-runtime.test.ts', + 'tests/homeworld-skills-discovery-runtime.test.ts', + 'tests/homeworld-optional-modules-runtime.test.ts', + 'tests/homeworld-native-e1e2-modules-runtime.test.ts', + 'tests/homeworld-native-e3-modules-runtime.test.ts', + 'tests/homeworld-classic-nexus-skills-runtime.test.ts', + 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', + 'tests/homeworld-paired-nexus-runtime.test.ts', + 'tests/homeworld-standalone-nexus-runtime.test.ts', + 'tests/e3-nexus-ecaz-runtime.test.ts', + 'tests/e3-nexus-moritani-runtime.test.ts', + 'tests/homeworld-custody.test.ts', + 'tests/homeworld-cards.test.ts', + 'tests/homeworld-payment-income.test.ts', + 'tests/homeworld-payment-income-engine.test.ts', + 'tests/homeworld-payment-income-recovery.test.ts', + 'tests/homeworld-collection.test.ts', + 'tests/homeworld-collection-engine.test.ts', + 'tests/homeworld-collection-recovery.test.ts', + 'tests/homeworld-grumman-engine.test.ts', + 'tests/homeworld-grumman-recovery.test.ts', + 'tests/homeworld-setup-engine.test.ts', + 'tests/homeworld-actions-engine.test.ts', + 'tests/homeworld-basic-counters.test.ts', + 'tests/homeworld-salusa-engine.test.ts', + 'tests/homeworld-combat.test.ts', + 'tests/homeworld-battle-resolution.test.ts', + 'tests/homeworld-battle-engine.test.ts', + 'tests/homeworld-battle-recovery.test.ts', + 'tests/homeworld-shipment-engine.test.ts', + 'tests/homeworld-shipment-recovery.test.ts', + 'tests/homeworld-shipment-controls.test.ts', + 'tests/homeworld-shipment-review.test.ts', + 'tests/guild-homeworld-shipment-engine.test.ts', + 'tests/guild-homeworld-shipment-recovery.test.ts', + 'tests/guild-homeworld-shipment-controls.test.ts', + 'tests/junction-transport.test.ts', + 'tests/junction-transport-engine.test.ts', + 'tests/junction-transport-recovery.test.ts', + 'tests/junction-transport-controls.test.ts', + 'tests/junction-transport-review.test.ts', + 'tests/homeworld-card-economy.test.ts', + 'tests/homeworld-tupile-sales-engine.test.ts', + 'tests/homeworld-tupile-sales-recovery.test.ts', + 'tests/bidding-end-engine.test.ts', + 'tests/bidding-end-controls.test.ts', + 'tests/bidding-end-recovery.test.ts', + 'tests/ecaz-poison-income.test.ts', + 'tests/ecaz-poison-income-engine.test.ts', + 'tests/homeworld-mobility.test.ts', + 'tests/homeworld-mobility-engine.test.ts', + 'tests/homeworld-mobility-recovery.test.ts', + 'tests/homeworld-spiritual-advisors.test.ts', + 'tests/homeworld-spiritual-advisors-recovery.test.ts', + 'tests/homeworld-advisor-ambassador.test.ts', + 'tests/homeworld-replacement-engine.test.ts', + 'tests/homeworld-recovery.test.ts', + 'tests/homeworld-charity-engine.test.ts', + 'tests/homeworld-revival-benefits-engine.test.ts', + 'tests/homeworld-revival-deployment.test.ts', + 'tests/homeworld-revival-return.test.ts', + 'tests/homeworld-revival-destinations.test.ts', + 'tests/homeworld-revival-deployment-engine.test.ts', + 'tests/homeworld-revival-deployment-controls.test.ts', + 'tests/homeworld-revival-deployment-recovery.test.ts', + 'tests/homeworld-victory-reinforcement.test.ts', + 'tests/caladan-reinforcement-controls.test.ts', + 'tests/homeworld-victory-reinforcement-recovery.test.ts', + 'tests/tupile-intelligence.test.ts', + 'tests/tupile-intelligence-answer.test.ts', + 'tests/tupile-intelligence-state.test.ts', + 'tests/tupile-intelligence-controls.test.ts', + 'tests/tupile-intelligence-engine.test.ts', + 'tests/tupile-intelligence-recovery.test.ts', + 'tests/homeworld-occupation-history.test.ts', + 'tests/homeworld-occupied-income.test.ts', + 'tests/homeworld-occupied-percentage.test.ts', + 'tests/homeworld-occupied-bidding.test.ts', + 'tests/homeworld-occupied-defenses.test.ts', + 'tests/homeworld-occupation-runtime.test.ts', + 'tests/homeworld-occupied-producers-engine.test.ts', + 'tests/homeworld-occupied-private-lots.test.ts', + 'tests/homeworld-occupied-tupile.test.ts', + 'tests/homeworld-occupied-tupile-runtime.test.ts', + 'tests/ecaz-homeworld-victory.test.ts', + 'tests/ecaz-homeworld-victory-runtime.test.ts', + 'tests/grumman-collection.test.ts', + 'tests/grumman-collection-engine.test.ts', + 'tests/grumman-collection-controls.test.ts', + 'tests/grumman-collection-recovery.test.ts', + 'tests/grumman-selection-integrity.test.ts', + 'tests/homeworld-benefits-recovery.test.ts',], }, ], }, @@ -4094,11 +4090,11 @@ export const RULE_TOPICS: RuleTopic[] = [ summary: 'A development prototype connects Great Maker, six Discovery spice blows, private token inspection, stash rewards, revealed locations, next-turn entry, Cistern income, bounded Orgiz transfers, Ornithopter movement, Jacurutu income, Testing Station and Shrine. Remaining interpretations and normal public starts stay gated.', searchText: 'Hiereg Smuggler Jacurutu Sietch Cistern Ecological Testing Station Shrine Orgiz Processing Station Treachery Card Stash Spice Stash Ornithopter', steps: [ - 'Fresh Discovery development setup supports classic, selected Ixian/Tleilaxu/CHOAM/Richese families, or standalone Ecaz or Moritani with classic opponents in Basic or Advanced, with seven extra Spice Cards and eight tokens. Original unused Tech Tokens may be selected with three through six seats; original Stronghold Cards require Advanced. Other modules, paired or mixed Ecaz/Moritani families and normal public starts stay gated.', - 'Fresh Discovery retains Homeworlds in Basic/Advanced two through six classic or supported native E1/E2 seats, or standalone Ecaz OR Moritani with classic opponents, original seven cards/eight tokens and native reserve placement. Original Tech requires three or more seats; Stronghold Cards require Advanced. The separate original leader-skills --discoveries entry composes all fourteen Skills. Classic, single or paired Ixians/Tleilaxu or CHOAM/Richese with one family deck, or standalone Ecaz OR Moritani entries retain all12 Nexus through the original Nexus entry without Skills, or their existing skill entry. Maker, later parent entry, nested shipment/rescue, phase-end industry and cleanup before mandatory winner Tech retain actual sources; held benefits never spill into a Homeworld or unrelated nested site. Mixed-deck Nexus, unsupported E3 pairs/mixed families and unrelated overlays remain separate in this bounded profile, not outside the full goal.', - 'The fresh Leader Skills entry may explicitly add Discoveries with original classic or supported native decks, all fourteen skills, original Tech at three or more seats and Advanced Stronghold Cards. Original Homeworlds may be retained; --nexus-cards requires classic factions, single or paired Ixians/Tleilaxu OR CHOAM/Richese with one family deck, or standalone Ecaz OR Moritani with classics. All12 Nexus retain the actual settled-alliance end-Spice closing deal after Great Maker and both Advanced piles. Existing native roster and Duke/assassination/Harkonnen restrictions remain. Actual hands/offers, physical entry, nested tariffs, stash capacity, typed rescue, native aftermath and printed invoices retain their sources. Mixed decks, unsupported E3 pairs/mixed families, independent variants and separate Banker/Mentat previews remain outside these bounded compositions.', - 'The same fresh entry also adds --nexus-cards --discoveries to single or paired Ixians/Tleilaxu, single or paired CHOAM/Richese or standalone Ecaz OR Moritani with classics, optionally retaining original Homeworlds: all14 offered training, all12 Nexus and Discovery7/8, Basic/Advanced two through six, optional Tech at three or more seats and Advanced Strongholds. Original setup, first hands, offers and native components stay authoritative. Real Maker losses/votes/typed rides, both Advanced piles and settled alliance precede the end-Spice closing draw. E1 Suboid Cunning settles typed Suk rescue before one equal Cyborg Tank substitution; original cleanup and mandatory winner Tech precede a matching Face Dance, whose foreign-Homeworld suppression remains. E2 keeps the signed two-marker frame and one invoice. Ecaz keeps quiet living/unclaimed Duke separate from training and a real owner-labelled Arrakis Occupy; Moritani keeps skill-first assassination and one private Mentat replacement. Mixed/E3 pairs, shared Duke, borrowed Smuggler/pair companions, native free returns, captured/foreign training, Basic odd-force Ecaz and Advanced Harkonnen stay separate.', - 'A separate fresh Nexus entry may explicitly add Discoveries to classic factions, single or paired native Ixians/Tleilaxu OR CHOAM/Richese with classics and one family deck, or standalone Ecaz OR Moritani with classics and one original ecaz deck, without Skills and optionally retaining original Homeworlds. Original Tech requires three or more seats; Stronghold Cards require Advanced. Native Ecaz Arrakis Occupy and Advanced Moritani assassination retain their original source/effect exclusions; no training component or starting Nexus deal is added. End-Mentat ownership and next-turn physical entry remain. Great Maker finishes its losses, votes and typed reserve ride before ordinary Nexus settlement; cards are offered only at the end of the entire Spice Blow after both Advanced piles and settled alliance, not immediately or at Mentat. Source-clear ledgers, Guild-priced nested shipment and signed native one-invoice marker pairs retain physical pieces and original capacities. E3pairs/mixed families, unrelated previews, unresolved native free returns and mixed-module Fremen Betrayal remain separate.', + 'The Discovery-only fresh entry supports classics, selected Ixian/Tleilaxu/CHOAM/Richese families or standalone Ecaz OR Moritani with classics, Basic/Advanced2–6, seven extra Spice Cards/eight tokens, optional unused Tech3+ and Advanced Strongholds. Its separate Nexus/Skills entries below admit their additional exact compositions; Discovery-only paired or mixed E3 and public starts remain gated.', + 'Fresh Discovery retains original Homeworlds and native reserves in classic, supported E1/E2 or standalone E3 tables, Basic/Advanced2–6, optional Tech3+/Advanced Strongholds. Original Nexus entries additionally admit natives from BOTH Ix+CHOAM families/union47 or BOTH Ecaz+Moritani/ecaz33 with classics, with or without all14 Skills. Maker, actual parent/nested entry, source-local payments/rescue, phase-end industry and original-winner cleanup/Tech remain. Held benefits do not migrate into nested locations. Original capture/roster and unresolved-effect guards remain.', + 'The original leader-skills --nexus-cards --discoveries entry composes all14 offered training/all12 Nexus/Discovery7/8 for classics/base33, single or paired one-family E1/E2, natives from BOTH selected Ix+CHOAM families/union47, or standalone OR paired Ecaz/Moritani/ecaz33 with classics. Original Homeworlds may be retained; Tech requires3+ and Strongholds Advanced. Hands/offers, native components and actual Maker/both-pile/alliance-change closing draws remain authoritative. Shared Duke never trains; Advanced paired E3/Moritani excludes Harkonnen. Mixed E3/three-family and pending effects/public starts/save conversion remain separate.', + 'Connected native outcomes use original sources: Suboid Cunning and winning trained Suk settle typed fixed casualties before one equal actual Cyborg Tank substitution; original cleanup and mandatory winner Tech precede matching Face Dance, retaining its foreign-Homeworld suppression. Signed Richese two-marker one-invoice physical shipment requires a currently revealed nested site. Ecaz-selected coalition armies and Moritani skill-first assassination remain distinct from shared-Duke training. These programmes do not certify every skill or card band.', + 'The original nexus --discoveries entry retains NO Skills with classics/base33, single or paired one-family E1/E2, both selected Ix+CHOAM native families/union47, or standalone OR paired Ecaz/Moritani/ecaz33 with classics. Original Homeworlds/Tech3+/Advanced Strongholds remain optional. Real native Traitors, Arrakis Occupy and Advanced assassination marks remain; no starting Nexus hand or training is added. Maker losses/votes/typed reserve rides, both Advanced piles and actual settled alliance change precede the closing card deal. Source guards and public/played-save boundaries remain.', 'Original Stronghold Cards are claimed only at end-Mentat. A held benefit applies in its named territory or the mobile stronghold through its actual declared copy; revealed Discovery locations do not inherit another territory’s defense or bank support. Jacurutu counts toward stronghold victory but creates no seventh Stronghold Card. Physical free entry, nested tariffs, trained or native force casualties, card cleanup and optional Tech reward retain their original sources.', 'Great Maker first resolves the ordinary worm consequences. All players then vote in storm order on creating a Nexus. A strict majority is required; a tie adds no Nexus and an existing Nexus in the phase stays recorded. Fremen may then ride with actual reserve forces to one legal destination for free, without using ordinary shipment or movement. Storm and occupancy restrictions still apply.', 'Each of the six Discovery territory cards destroys prior spice and forces in the named blow territory, including Fremen forces, before placing six new spice subject to the storm. It also places a random available face-down token of the printed type at the card’s separate token destination.', @@ -4138,11 +4134,9 @@ export const RULE_TOPICS: RuleTopic[] = [ }, { area: 'Verification', status: 'Partial', - detail: 'Single-native E1/E2 Nexus adds15 original programmes and398/398 affected checks across35 files, types/lint pass. Across32 families,190/192 genuine six-seat games finish208205/208213 actions with5539 JSON across two frozen trees after one real ordinary-skill consumer omission is repaired through the shared predicate. Exhausted-cache/low-Tupile captures remain guarded. Actual390px typed native Cistern entry, Maker/both-pile alliance closing, human Suboid Cunning/equal exchange and native one-invoice five-plus-three reveal preserve original20-counter budgets. Earlier source-qualified evidence remains canonical; full rules/combinations and deployed acceptance remain open.', - evidence: ['tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', - 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', - 'tests/homeworld-paired-nexus-runtime.test.ts', 'tests/homeworld-standalone-nexus-runtime.test.ts', - 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', 'tests/homeworld-native-e1e2-modules-runtime.test.ts', 'tests/homeworld-native-e3-modules-runtime.test.ts', 'tests/homeworld-classic-discovery-runtime.test.ts', 'tests/homeworld-skills-discovery-runtime.test.ts', 'tests/homeworld-optional-modules-runtime.test.ts', 'tests/discovery-native-e1-skills-runtime.test.ts', 'tests/discovery-native-e2-skills-runtime.test.ts', 'tests/discovery-native-e3-skills-runtime.test.ts', 'tests/discovery-skills-strongholds-runtime.test.ts', 'tests/discovery-native-strongholds-runtime.test.ts', 'tests/ecaz-stronghold-runtime.test.ts', 'tests/e3-native-tech-runtime.test.ts', 'tests/discovery-classic-nexus-lifecycle.test.ts', 'tests/discovery-classic-nexus-effects.test.ts', 'tests/discovery-orgiz-advanced-runtime.test.ts', 'tests/discovery-classic-skills-runtime.test.ts', 'tests/discovery-module-profile.test.ts', 'tests/discovery-native-typed-runtime.test.ts', 'tests/discovery-native-marker-runtime.test.ts', 'tests/discovery-native-e3-runtime.test.ts', 'tests/discoveries.test.ts', 'tests/discovery-runtime.test.ts', 'tests/great-maker.test.ts', 'tests/discovery-board.test.ts', 'tests/discovery-admission.test.ts', 'tests/discovery-controls.test.ts', 'tests/discovery-entry.test.ts', 'tests/discovery-entry-runtime.test.ts', 'tests/discovery-entry-controls.test.ts', 'tests/discovery-entry-review.test.ts', 'tests/discovery-collection.test.ts', 'tests/discovery-flight.test.ts', 'tests/discovery-flight-engine.test.ts', 'tests/discovery-flight-recovery.test.ts', 'tests/discovery-battle.test.ts', 'tests/discovery-battle-recovery.test.ts', 'tests/discovery-storm.test.ts', 'tests/discovery-storm-recovery.test.ts', 'tests/discovery-shrine-engine.test.ts', 'tests/discovery-shrine-recovery.test.ts', 'docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md'], + detail: 'Mixed E1/E2 and paired E3 preserves Discovery7/8/all12 Nexus, optional all14/HW/Tech/Advanced Strongholds. New31 physical programmes plus2 stock regressions; final489/489 affected checks across42 files pass with types/lint. Across64 original families,258/288 games finish338931/339426 actions and9013 JSON across three frozen trees and preserved continuations. Native Ecaz assassination, actual separate Loyalty stock and existing selected overlap order are repaired without new printed rulings. Actual original Maker votes/both piles and native card draws retain physical sources; no human nested entry or winning Suk is invented. Source guards and full acceptance remain open.', + evidence: ['tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/homeworld-paired-nexus-runtime.test.ts', 'tests/homeworld-standalone-nexus-runtime.test.ts', + 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', 'tests/homeworld-native-e1e2-modules-runtime.test.ts', 'tests/homeworld-native-e3-modules-runtime.test.ts', 'tests/homeworld-classic-discovery-runtime.test.ts', 'tests/homeworld-skills-discovery-runtime.test.ts', 'tests/homeworld-optional-modules-runtime.test.ts', 'tests/discovery-native-e1-skills-runtime.test.ts', 'tests/discovery-native-e2-skills-runtime.test.ts', 'tests/discovery-native-e3-skills-runtime.test.ts', 'tests/discovery-skills-strongholds-runtime.test.ts', 'tests/discovery-native-strongholds-runtime.test.ts', 'tests/ecaz-stronghold-runtime.test.ts', 'tests/e3-native-tech-runtime.test.ts', 'tests/discovery-classic-nexus-lifecycle.test.ts', 'tests/discovery-classic-nexus-effects.test.ts', 'tests/discovery-orgiz-advanced-runtime.test.ts', 'tests/discovery-classic-skills-runtime.test.ts', 'tests/discovery-module-profile.test.ts', 'tests/discovery-native-typed-runtime.test.ts', 'tests/discovery-native-marker-runtime.test.ts', 'tests/discovery-native-e3-runtime.test.ts', 'tests/discoveries.test.ts', 'tests/discovery-runtime.test.ts', 'tests/great-maker.test.ts', 'tests/discovery-board.test.ts', 'tests/discovery-admission.test.ts', 'tests/discovery-controls.test.ts', 'tests/discovery-entry.test.ts', 'tests/discovery-entry-runtime.test.ts', 'tests/discovery-entry-controls.test.ts', 'tests/discovery-entry-review.test.ts', 'tests/discovery-collection.test.ts', 'tests/discovery-flight.test.ts', 'tests/discovery-flight-engine.test.ts', 'tests/discovery-flight-recovery.test.ts', 'tests/discovery-battle.test.ts', 'tests/discovery-battle-recovery.test.ts', 'tests/discovery-storm.test.ts', 'tests/discovery-storm-recovery.test.ts', 'tests/discovery-shrine-engine.test.ts', 'tests/discovery-shrine-recovery.test.ts', 'docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md'], }, ], }, @@ -4422,12 +4416,12 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Before drawing, choose whether to keep your own faction card, redraw it once, or redraw whenever it appears. Its effect is available in the reference before making this choice. The server applies that preference immediately; the table never pauses in a way that identifies a card you chose to keep.', 'Entering any alliance discards your held Nexus Card, including alliances made through an Ambassador or Terror token. An offer that has not been accepted does not discard it.', 'Use Cunning for your own faction, Betrayal when another player controls the printed faction, and Secret Ally when that faction is absent. Used cards go to the separate Nexus discard pile. Each printed effect has its own timing.', - 'Fresh local classic Nexus tables retain original Homeworlds with optional Tech Tokens in Basic/Advanced three-through-six-seat lobbies and/or Stronghold Cards in Advanced two-through-six-seat lobbies; selecting both requires three or more. Original base33/all12 setup, closing Nexus deals, first-Storm Tech and actual end-Mentat Stronghold custody remain. Separate leader-skills --nexus-cards composes all14 original training with these modules, optionally Discovery. Original paired E1/E2 and standalone E3 entries retain their own documented native roster/deck/effect boundaries. Unrelated previews, public starts and saved-game conversion remain separate.', - 'Fresh nexus --discoveries preserves original Tech and Advanced Stronghold prerequisites with classic factions, single or paired native Ixians/Tleilaxu OR CHOAM/Richese with one family deck, or standalone Ecaz OR Moritani with classics and ecaz33, without Skills. These entries may retain original Homeworlds. The original seven Discovery Spice Cards/eight tokens, protected nested locations, next-turn physical entry and Great Maker losses/votes/typed native reserve ride remain. The closing draw follows both Advanced piles and settled alliances; a preceding Shai-Hulud’s completed losses do not repeat. Source-clear quotas, borrowed Guild tariffs and signed native two-marker one-invoice shipments into currently revealed sites use original controls. E3pairs/mixed families, unresolved native free-return and mixed-module Fremen Betrayal or other unfinished effects remain guarded; no public start or save conversion.', - 'The fresh local Nexus entry also composes Tech and/or Advanced Strongholds with single or paired Ixians/Tleilaxu OR CHOAM/Richese and classic opponents, one original family deck and no Skills, optionally original Homeworlds. Discovery may be selected through --discoveries, including without Tech or Strongholds. Tech needs three through six seats; Strongholds needs two through six Advanced seats. Real closing draws, native typed forces, physical Face Dancers/No-Fields, original payments/rewards and held effects remain. The paired initializer still requires both native factions; single-native games use the existing generic initializer and original faction setup. Mixed families, unrelated previews, public starts and unresolved rulings remain separate.', + 'Fresh original Nexus tables may retain Homeworlds, Discovery7/8, Tech3+ Basic/Advanced and Strongholds2+ Advanced, optionally all14 through leader-skills --nexus-cards. Original roster/deck envelopes are classics/base33, single or paired one-family E1/E2, both selected Ix+CHOAM native families/union47, or standalone OR paired Ecaz/Moritani/ecaz33 with classics. Printed/native setup, first-Storm Tech, actual end-Mentat claims and all12 closing draws remain. Advanced paired E3/Moritani excludes Harkonnen; shared Duke never trains. Public starts, played-save conversion, mixed E3/three-family and pending effects remain separate.', + 'Fresh nexus --discoveries uses the original no-Skills envelopes above with optional Homeworlds, Tech3+ and Advanced Strongholds. Seven Discovery Spice Cards/eight tokens, protected nested sites, next-turn physical entry and actual Maker losses/votes/reserve ride remain. The card draw follows both Advanced piles and actual settled alliance change; earlier Shai-Hulud losses never repeat. Source-clear quotas, borrowed Guild tariffs and signed native one-invoice pairs at revealed sites use original controls, not new transport adapters.', + 'Without Skills, the existing generic Nexus initializer handles single and mixed selected E1/E2 natives, while paired one-family and paired E3 entries use their original paired initializer and require BOTH original natives. Native HMS/Face Dancers/Auditor/Richese cache, Ecaz ordinary discs/Loyalty/shared Duke and Advanced Moritani assassination remain physical. New overlays do not create a first Nexus hand, free native return, arbitrary marker companion or played-game conversion.', 'The separate fresh local leader-skills --nexus-cards entry combines classic base33/all14 Skills/all12 Nexus in Basic or Advanced, two through six seats, optionally adding original Discovery7/8 through --discoveries. Original Tech requires three or more seats; Stronghold Cards require Advanced. Starting hands/offers, actual end-Spice closing draws, first-Storm Tech and end-Mentat held custody remain. Extra/ordinary free-three returns earn original phase-end Axlotl without changing skill custody. Emperor Cunning composes skill posture, support and Suk rescue; Arrakeen reduces payer cost, not committed support or rescue eligibility. Winner cleanup precedes mandatory Tech reward. Unrelated overlays and public starts remain guarded.', - 'The same fresh skill/Nexus entry supports single or paired Ixians/Tleilaxu OR CHOAM/Richese with classics and one original family deck/all14/all12, Basic/Advanced two through six seats. Selected original Tech requires three or more; Strongholds requires Advanced. Full Suboid Cunning composes typed skill rescue before equal actual Cyborg Tank substitution. Original held subsidy changes debit, not rescue support. Card cleanup and mandatory original-winner Tech precede Face Dance; a matching trainer dies and returns its skill once without another bounty, while held cards and tokens remain with the original winner. Partial Tleilaxu refresh preserves Mentat rights. Original signed Richese pair and CHOAM fuel/industry stay distinct. Original all14 Auditor exclusion and native-only optional own-disc revival training remain. Mixed families, E3, companions/mixed plans, native free-return accounting and pending rulings stay guarded.', - 'A separate standalone Ecaz OR Moritani skill/Nexus entry uses exact ecaz33/all14/all12 with classics, Basic/Advanced two through six seats, optionally original Homeworlds and Discovery; original Tech requires3+, Strongholds requires Advanced. Actual closing draws, five ordinary training discs, quiet living/unclaimed Duke Cunning and original Advanced assassination/normal-Traitor forfeiture remain. Native support/rescue, held effects, original card cleanup and mandatory winner Tech use existing handlers. Advanced Harkonnen, E3 pairs/mixed families/previews, Duke training/exceptional custody, enhanced Terror relocation/HMS/Grumman and other pending rulings stay guarded.', + 'The original all14 skill/Nexus envelope admits single or paired one-family E1/E2, BOTH selected Ix+CHOAM native families/union47, and standalone OR paired E3/ecaz33 with classics, Basic/Advanced2–6, optional Homeworlds/Discovery7/8/Tech3+/Advanced Strongholds. Full Suboid support composes winning typed fixed-loss Suk before equal actual Cyborg Tank substitution. Held subsidy changes debit, not rescue entitlement. Original cleanup/winner Tech precedes Face Dance; the replaced trainer dies/returns its skill once without another bounty, while earlier held rewards stay with the original winner. Original Auditor exclusion, signed Richese invoices, CHOAM fuel, native cache and pending-effect guards remain.', + 'Standalone OR paired Ecaz/Moritani original skill/Nexus entries use ecaz33/all14/all12 and classics, Basic/Advanced2–6, optional Homeworlds/Discovery/Tech3+/Advanced Strongholds. Each original roster/capture guard remains, including Advanced Harkonnen exclusion and no shared-Duke training. Quiet living/unclaimed Duke Cunning and native Advanced assassination use original distinct discs and exact continuation. In paired tables, native Ecaz may be a different opposing-faction assassination target; normal foreign-world Traitor prohibition does not become an assassination ban. Exceptional Duke custody, mixed E3/three-family, enhanced Terror/HMS/Grumman and pending effects remain separate.', 'The original nexus entry also admits standalone Ecaz OR Moritani with classics, ecaz33/all12 and NO Skills, Basic/Advanced2–6, optionally Homeworlds, Discovery7/8, Tech3+ and Advanced Strongholds2+. Original Traitors/native setup precede a qualifying closing draw, with no starting Nexus hand. Quiet Duke Cunning stays separate from shared-disc training; no-Skills Ecaz may face Advanced Harkonnen without lifting the Cunning exclusion. Advanced Moritani excludes Harkonnen, retains post-loss private assassination/normal-call forfeiture and Mentat replacement, and uses supply-only Terror Cunning. Original Ecaz-selected coalition losses/payments and source-local held benefits/Tech remain. A sole loser token transfers automatically after cleanup; a real multi-token choice remains mandatory. Other native/mixed families, borrowed free-return/companion and relocation rulings, public starts and save conversion remain separate.', 'Atreides Cunning adds a second, different opposing plan element after the first native answer. Both remain binding. Use it in your own battle before sealing your plan; Karama may cancel the extra attempt while preserving the first disclosure.', 'With Atreides absent, Secret Ally inspects one opposing element in your battle. A sealed plan supplies only that selected answer automatically. Otherwise the opponent chooses an answer that permits a legal plan. Other players receive no private answer.', @@ -4460,11 +4454,11 @@ export const RULE_TOPICS: RuleTopic[] = [ 'When Moritani is absent, a defeated combatant with an eligible publicly played card receives the same postbattle choice whether or not they hold Moritani Nexus. An unallied holder may spend it to retain one played card that a winner could keep; other played cards follow ordinary discard. Without a publicly eligible card there is no prompt. The choice is after the battle result and before later income/capture; the public prompt protects hidden custody and is an interface inference. This Secret Ally effect has no invented ordinary Karama response. Native Moritani allied retention remains a different, cancelable ability.', 'With actual Advanced Stronghold Cards, Arrakeen bank support changes the real payer debit, not personal income. Emperor Cunning temporary Sardaukar remain physically ordinary counters and retain ordinary Tanks custody. Original losing Moritani retention precedes mandatory original-winner Tech, then CHOAM inspection excludes used opposing cards even if retained. Ordinary city Collection remains separate; none of these windows repeats the original support, card cost or casualties.', 'Ecaz Betrayal is available to an unallied holder with Ecaz seated and reciprocally allied in a shared Arrakis territory. At the opening of Shipment and Movement, choose one such territory; the ally returns its entire normal and starred force group from every sector there to reserves, leaving Ecaz forces in place. The Nexus card is spent before a Karama response. Cancellation preserves the board but not the card. Only the base/Ecaz Nexus roster without combined modules is supported.', - 'Ecaz Cunning lets an unallied native holder spend the physical Nexus card at a quiet Battle boundary to take the existing living, unclaimed Duke Vidal for the turn, even from Moritani. The Duke can be selected through ordinary Battle plans and is set aside after use or at turn end. The original paired Ecaz/Moritani Nexus-only profile and supported standalone Ecaz Nexus profile with classic opponents admit this bounded effect, with or without Skills and optional original Homeworlds, Discovery, Tech and Advanced Strongholds. Duke remains separate from training. Advanced Harkonnen is independently excluded even when the no-Skills roster is otherwise admitted. Taking Duke from the Tanks, capture or Ghola custody remains unimplemented. The Battle-only timing is a product boundary, not printed text.', + 'Ecaz Cunning lets an unallied native holder spend its physical Nexus at a quiet Battle boundary to take the existing living, unclaimed Duke for this turn, even from Moritani. Original standalone OR paired Ecaz/Moritani/classic Nexus profiles admit this bounded effect with or without Skills and optional Homeworlds/Discovery/Tech/Advanced Strongholds. The one shared Duke remains separate from ordinary training and is set aside after use or at turn end. Advanced Harkonnen is independently excluded even where standalone no-Skills roster admission otherwise permits it. Captured, dead or foreign-Ghola Duke custody and unqualified reaction timing remain guarded; this Battle boundary is the existing product interpretation, not a printed phase restriction.', 'Fremen Betrayal has two bounded advance-declaration alternatives for an unallied holder in a classic Nexus table. Before the first Spice Blow or worm appearance, spend the physical card to prevent Fremen ordinary and Cunning worm rides for the turn; worm survival, destruction and Nexus still resolve. This clean timing is a product boundary, not an official post-appearance priority ruling. Instead, before Fremen move during Shipment and Movement, spend it to suppress their native two-territory advantage for that turn while ordinary movement and independent ornithopters remain. Only one alternative can be used even if the card is recycled later that turn. Owner controls show the currently available choice; combined modules and reactive post-worm timing remain gated.', 'When Ecaz is absent, an unallied holder may spend its Nexus card at a quiet play boundary to ask whether any one player, including themself, holds one of the holder’s native faction leaders as an ordinary Traitor Card. The owner receives a private yes/no answer saved at the moment of use; no name or count is disclosed. Dead or captured native leaders count, but foreign Gholas, Duke Vidal, Cheap Hero and Face Dancers do not. Native-leader scope and private-only audience are explicit product interpretations, not publisher rulings; exceptional controlled-leader and public-disclosure combinations remain open.', 'When Richese is absent, its Secret Ally replaces your ordinary reserve shipment with up to five physical forces charged as one. Select the explicit shipment checkbox; forces, elite identity, destination restrictions, Homeworld reserve sources and normal movement remain unchanged. Fremen remain free within their normal reinforcement range; Guild and an active Karama rate retain their existing tariff and payment routing. This is not a concealed No-Field or an extra shipment.', - 'Native Richese Cunning has a bounded two-No-Field shipment in genuine Richese/CHOAM Nexus games, including supported fresh Discovery/Tech/Advanced Strongholds without Skills or with the paired all14 Skills composition. Choose distinct unused tokens: one reveals immediately into reserve-capped public forces, the other remains concealed; pay for one marker and spend the Nexus. The previous shipment token cannot be either selection; the concealed token becomes next last-used. Both compositions retain the original signed pair frame and native response/Guild continuation, not a single-marker proof or free companion. A nested destination must remain currently revealed. Karama or Guild prevention preserves physical tokens and unpaid spice, with the original stopped-shipment policy. When Tech is selected, an accepted off-planet pair accrues Heighliners once at phase end. Other module combinations, mixed dialing and full faceup-component display remain gated.', + 'Native Richese Cunning uses the original signed two-No-Field shipment in supported single/paired E2 or selected mixed Ix+CHOAM native Nexus tables with classics, optional Skills/Homeworlds/Discovery/Tech/Advanced Strongholds. Select two distinct unused physical tokens: one reveals immediately into reserve-capped forces, one remains concealed; pay one marker invoice and spend the Nexus. The previous shipment token cannot be either choice, and the concealed one becomes last-used. Native/Guild response and actual payer/counter sources remain; a nested destination must be revealed. Prevention keeps physical tokens and unpaid spice, after any printed card cost. Sandtrout, arbitrary companions, shared allocation and other pending combinations retain their guards.', 'Richese Betrayal has a separate opt-in auction preview. Before payment, an unallied rival may spend its physical Richese Nexus card to discard a public self-cache win without any purchase debit, or redirect another buyer’s cache/Black Market payment to the bank while preserving delivery and earned bonus. Every publicly possible holder acknowledges the same opportunity; private Use eligibility cannot determine public timing. Normal hidden self-purchases and private special-Karama acquisitions are not connected. See the dedicated guide for its existing local runtime/browser evidence and remaining release gates; that evidence does not verify Nexus Kull.', 'Guild Cunning is declared when finishing your ordinary Shipment and Movement turn. It spends the Nexus and opens one Karama response. Allowance grants a second native shipment at normal Guild prices, including supported cross-shipment, return and Homeworld routes. Cancellation leaves the completed first turn intact. Only an unused Hajr move can follow the second shipment; no ordinary movement or third move is granted. The physical Ornithopter card combination remains unavailable. The separate guild-betrayal profile admits only original native shipment producers and explicitly blocks this second-shipment offer before Nexus spending; Cunning outside that profile is unchanged.', 'With Guild absent, its Secret Ally is an explicit optional source for one ordinary shipment at Guild prices. It supports physical reserve shipment, cross-shipment and return to abstract reserves, plus half-price independently legal world-to-world shipment. Fremen may retain free reinforcement or choose the paid card route beyond its normal range, outside the storm. Native faction, typed sources and normal movement stay unchanged. Homeworld reserve return awaits a ruling; arbitrary foreign-world transport and concealed No-Fields are not granted.', @@ -4479,7 +4473,7 @@ export const RULE_TOPICS: RuleTopic[] = [ { area: 'Player controls', status: 'Partial', detail: 'Private lifecycle, inspections, exchanges, Tleilaxu refresh, Ixian activation, advisor selection, temporary Sardaukar and CHOAM effects, Moritani Betrayal, Ecaz Betrayal and owner-only Secret Ally inquiry, native Duke Cunning, Richese shipment and bounded auction Betrayal, Emperor purchase and Fremen Cunning controls are connected. Remaining effect controls are unfinished.', evidence: ['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/nexus-ecaz-duke.tsx', 'components/nexus-richese-betrayal.tsx', 'components/game-table.tsx'] }, { area: 'AI', status: 'Partial', detail: 'All four profiles choose legal actions through private offers, including Moritani/Ecaz Betrayal, Ecaz Secret Ally inquiry and native Duke Cunning, Fremen Cunning, Emperor purchase and bounded Richese auction Betrayal. Complete module strategy and calibration remain unfinished.', evidence: ['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/nexus-ecaz-duke-options.ts', 'game/nexus-richese-options.ts', 'game/bot-nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts'] }, { area: 'Documentation', status: 'Partial', detail: 'Common rules, twelve printed faces and bounded runtime contracts retain explicit unresolved Ecaz Duke capture/Tanks/Ghola and inquiry audience/scope, Fremen nested Cunning, Great Maker, combined modules, fresh advisors, storm and borrowed Voice. Richese auction Betrayal has a separate cost/prepayment/privacy contract and excluded-producer boundaries. Guild Betrayal separately records full funded payment, own pre-funding, donor escrow once, the printed full-Junction override, four native adapters and neutral privacy; ordinary occupation/rounding and general response-priority questions remain unresolved.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_FREMEN_RULES.md', 'docs/NEXUS_ECAZ_RULES.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_RICHESE_RULES.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md', 'docs/NEXUS_GUILD_RULES.md'] }, - { area: 'Verification', status: 'Partial', detail: 'Single-native E1/E2 adds15 semantic programmes/398 affected checks across35 files, types/lint pass. All32 original six-seat families finish190/192 games with208205/208213 actions and5539 JSON across two frozen trees, including one repaired original bare-Skills Smuggler continuation. Exhausted-cache/low-Tupile source guards remain. Actual390px original trained Ixian offer/typed entry, Maker/both-pile draw, free Suboid support/equal nested Cyborg exchange and no-training Richese one-invoice5+3/reveal preserve20 physical counters. Earlier source-qualified evidence remains canonical, not new assurance or complete-mode/deployed certification.', evidence: ['tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', 'tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-controls.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts', 'tests/classic-nexus-skills-payments-runtime.test.ts', 'tests/classic-nexus-skills-battles-runtime.test.ts', 'tests/nexus-skills-modules-payments-runtime.test.ts', 'tests/nexus-skills-modules-battles-runtime.test.ts', 'tests/paired-ix-nexus-skills-modules-runtime.test.ts', 'tests/paired-choam-nexus-skills-modules-runtime.test.ts', 'tests/ecaz-nexus-skills-modules-runtime.test.ts', 'tests/moritani-nexus-skills-modules-runtime.test.ts', 'docs/NEXUS_CARD_RULES.md'] }, + { area: 'Verification', status: 'Partial', detail: 'Mixed E1/E2 and paired E3 adds31 physical programmes plus2 Loyalty-stock regressions, final489/489 affected checks across42 files and types/lint. All64 original six-seat families finish258/288 games with338931/339426 actions and9013 JSON across three frozen trees and preserved-prefix continuations. Native Ecaz assassination, separate physical Loyalty stock and exact paired overlap admission are repaired; all30 remaining traces retain printed source guards. Actual390px original human Ixian offer/training, typed shipment/free Suboid plan and paired Ecaz foreign arrival/living shared Duke preserve20 counters. Earlier evidence remains canonical; not complete modes, new assurance or protected deployed acceptance.', evidence: ['tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', 'tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-controls.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts', 'tests/classic-nexus-skills-payments-runtime.test.ts', 'tests/classic-nexus-skills-battles-runtime.test.ts', 'tests/nexus-skills-modules-payments-runtime.test.ts', 'tests/nexus-skills-modules-battles-runtime.test.ts', 'tests/paired-ix-nexus-skills-modules-runtime.test.ts', 'tests/paired-choam-nexus-skills-modules-runtime.test.ts', 'tests/ecaz-nexus-skills-modules-runtime.test.ts', 'tests/moritani-nexus-skills-modules-runtime.test.ts', 'docs/NEXUS_CARD_RULES.md'] }, ], }, { @@ -4662,7 +4656,7 @@ export const RULE_TOPICS: RuleTopic[] = [ developmentStage: 'Prototyped', summary: 'Fourteen physical skills, private assignment choices, five battle disciplines, Planetologist movement/battle effects and Suk Graduate rescues, Mentat/Bureaucrat scoring and Bureaucrat payment redirection, Sandmaster victory spice, Rihani exchanges, optional Smuggler shipping and unmodified battle collection, Sandmaster route and worm-ride collection, sealed Spice Banker battle spending and optional Diplomat base-defense copying in development tables. Other effects and combined-module acceptance remain unfinished.', steps: [ - 'Fresh Leader Skills retains Homeworlds in Basic/Advanced two through six classic or supported native E1/E2 seats, or standalone Ecaz OR Moritani with classic opponents, and all fourteen original offers, optionally original Discovery7/8, Tech at three or more seats and Advanced Stronghold Cards. Only classic factions additionally compose all12 Nexus through --nexus-cards. Native placement follows training, including the original Ixian pre-training card choice. Normal/trained Suk uses actual native, visitor or nested physical pools; native saved counters remain at that home. External Emperor returns explicitly allocate ordinary/elite counters to actual native homes, an implementation interpretation rather than ordinary revival placement. Exact-three Fremen Nexus revival separately deposits ordinary Emperor counters at Kaitain and Sardaukar at Salusa through the printed native return rule. Actual residual Cyborg Tanks feed optional equal Suboid substitution after rescue. Support precedes rescue; later Cistern income, source-local held effects and mandatory winner Tech after original cleanup stay separate. Bureaucrat invoices and minimal legal bots are connected. Native Homeworld/Nexus, unsupported E3 pairs/mixed families, unrelated overlays and pending interactions stay separate.', + 'Fresh all14 Leader Skills preserves original classic, supported E1/E2 or standalone Ecaz OR Moritani setup with Homeworlds/Discovery/Tech3+/Advanced Strongholds under each envelope. Explicit --nexus-cards additionally admits single/paired one-family E1/E2, BOTH selected Ix+CHOAM native families/union47 and standalone OR paired Ecaz/Moritani/ecaz33 with classics. Basic/Advanced2–6, original Ix pre-training offer, ordinary native discs and actual hands remain; Auditor/shared Duke never train. Winning normal/trained Suk uses source-labelled native, visitor or nested fixed-loss pools; native saved counters stay at that home and external Emperor returns allocate original homes. Original phase-end industry, held benefits, cleanup/Tech before Face Dance and native assassination remain; unreconciled bands, mixed E3/three-family, public starts and played-save conversion stay separate.', 'Basic Moritani retains base opponents including Harkonnen. Its separate bounded Advanced classic/non-Harkonnen roster now composes the original assassination preview and all fourteen skills: winner Suk rescue and Rihani choice finish first, then an eligible different trained disc may be assassinated with its exact card return and printed bounty. No casualties repeat; original set-aside and one private Mentat replacement remain. See Moritani with Leader Skills for the exact scope.', 'Fresh Advanced profiles compose native Ixians, Tleilaxu, CHOAM and/or Richese with classic opponents and distinct required family decks. All fourteen skills and native starts remain. Auditor stays unskilled; cyborg casualties, original CHOAM support and Richese capped marker revelation remain physical. Foreign revival creates no new skill. Native Ixian Technology on Richese lots, own mixed No-Field, captured replacement, Advanced Atreides Suk/KH and unrelated overlays retain their guards.', 'Fresh three-through-six-seat Basic/Advanced development tables may compose all fourteen Skills with original Tech Tokens: classic factions, supported E1/E2 native families, or standalone Ecaz/Moritani with the exact ecaz deck and original roster exclusions. Starting cards/cache, skill/Traitor choices and printed/first-Storm assignment stay genuine. Existing leader-skills/banker-income entry preserves unused selected tokens; mixed E3 families, other overlays, public activation and saved-game conversion remain separate.', diff --git a/tests/discovery-module-profile.test.ts b/tests/discovery-module-profile.test.ts index 38105b23..802d266b 100644 --- a/tests/discovery-module-profile.test.ts +++ b/tests/discovery-module-profile.test.ts @@ -54,12 +54,10 @@ void test('Discovery cannot redeal started components or silently remove another void test('Discovery Skills cannot silently drop unsupported native families or unrelated modules and previews', () => { const unsupported = lobby(['ixians', 'ecaz', 'guild'], ['ix', 'ecaz']); assert.throws(() => initializeLeaderSkillsGameForAudit(unsupported), RuleError); - const overlays = Array.from({ length: 4 }, () => lobby(['guild', 'emperor', 'harkonnen'], [])); - overlays[0] = lobby(['ixians', 'choam', 'guild'], ['ix', 'choam']); - overlays[0].nexusCards = { cards: null, phase: null }; - overlays[1].advanced = false; overlays[1].strongholdCards = createStrongholdCards(); - overlays[2].mentatQuestionPreview = true; - overlays[3].spiceBankerIncomePreview = true; + const overlays = Array.from({ length: 3 }, () => lobby(['guild', 'emperor', 'harkonnen'], [])); + overlays[0].advanced = false; overlays[0].strongholdCards = createStrongholdCards(); + overlays[1].mentatQuestionPreview = true; + overlays[2].spiceBankerIncomePreview = true; for (const game of overlays) assert.throws(() => initializeLeaderSkillsGameForAudit(game), RuleError); const two = lobby(['guild', 'emperor'], []); two.techTokens = createTechTokens(); assert.throws(() => initializeLeaderSkillsGameForAudit(two), RuleError); diff --git a/tests/e3-nexus-moritani-runtime.test.ts b/tests/e3-nexus-moritani-runtime.test.ts index 4b02d0a8..006c47b9 100644 --- a/tests/e3-nexus-moritani-runtime.test.ts +++ b/tests/e3-nexus-moritani-runtime.test.ts @@ -331,12 +331,9 @@ void test('original Discovery supply, reveal and free nested entry survive the l physical(done); }); -void test('Advanced Harkonnen and mixed-E3 original lobbies remain inadmissible without mutating the declared source pieces', () => { +void test('Advanced Harkonnen original lobbies remain inadmissible without mutating declared source pieces', () => { const native = lobby(); joinGame(native, newPlayer('harkonnen', 'Harkonnen', 'harkonnen')); - const mixed = lobby(); joinGame(mixed, newPlayer('ecaz', 'Ecaz', 'ecaz')); - for (const g of [native, mixed]) { - let ready = reload(g); - for (const p of ready.players) ready = act(ready, { actor: p.id, action: { type: 'ready' } }); - const before = structuredClone(ready); assert.throws(() => initializeNexusGameForAudit(ready)); assert.deepEqual(ready, before); - } + let ready = reload(native); + for (const p of ready.players) ready = act(ready, { actor: p.id, action: { type: 'ready' } }); + const before = structuredClone(ready); assert.throws(() => initializeNexusGameForAudit(ready)); assert.deepEqual(ready, before); }); diff --git a/tests/fixture-mixed-nexus-modules.ts b/tests/fixture-mixed-nexus-modules.ts new file mode 100644 index 00000000..4531318d --- /dev/null +++ b/tests/fixture-mixed-nexus-modules.ts @@ -0,0 +1,333 @@ +import assert from 'node:assert/strict'; +import { + applyAction, createGame, initializeLeaderSkillsGameForAudit, initializeNexusGameForAudit, + joinGame, newPlayer, viewGame, +} from '../game/engine'; +import type { Action, Game } from '../game/engine'; +import type { FactionId } from '../game/catalog'; +import type { LeaderSkillId } from '../game/leader-skill-cards'; +import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards'; +import { treacheryDeck } from '../game/cards'; +import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards'; +import { validateLeaderSkills } from '../game/leader-skills'; +import { richeseCards } from '../game/richese-cards'; +import { territory } from '../game/board'; +import { traitorDeck } from '../game/traitors'; +import { DISCOVERY_CARD_PLACEMENTS, validateDiscoveryState } from '../game/discoveries'; +import { discoveryEntryMoveAction } from '../game/discovery-entry-options'; +import { greatMakerRideAction } from '../game/great-maker-options'; +import { nativeShipmentSources } from '../game/homeworld-options'; +import { nextSingleNexusE1Step, shipSingleNexusE1, quoteSingleNexusE1Battle, + openSingleNexusE1Battle, revealSingleNexusE1Plans } from './fixture-single-nexus-e1'; +import { nativeHomeworldInventory, nativeHomeworldPlayer, nativeHomeworldLeader, + frontNativeHomeworldSpice } from './fixture-homeworld-native-e1e2-modules'; +import { withClassicDiscoveryNexusLottery, withClassicDiscoveryNexusToken } from './fixture-discovery-classic-nexus'; + +export interface MixedNexusStep { actor: string; action: Action } +export interface MixedNexusTransition { before: Game; step: MixedNexusStep; after: Game } +export interface MixedNexusOptions { + initial?: Game; + advanced?: boolean; + skills?: boolean; + homeworlds?: boolean; + discovery?: boolean; + tech?: boolean; + strongholds?: boolean; + roster?: readonly FactionId[]; + /** Selects only an original undealt offer, never changes a supplied offer. */ + skillOwner?: FactionId; + skill?: LeaderSkillId; + fuelKind?: 'special' | 'shield'; +} +export interface MixedNexusSetup { + initial: Game; setup: Game; offered: Game | null; ixOffer: MixedNexusTransition | null; + afterSetup: Game; trainers: Record; dancerLeader: string | null; + seats: Partial>; actions: MixedNexusStep[]; +} +export interface MixedNexusProgramme extends MixedNexusSetup { + game: Game; + firstArrival: MixedNexusTransition | null; + firstMarker: MixedNexusTransition | null; + reveal: MixedNexusTransition | null; + entry: MixedNexusTransition | null; + vote: Game | null; + ride: MixedNexusTransition | null; + firstMentat: MixedNexusTransition | null; + alliance: Game | null; + drawing: Game | null; + draws: MixedNexusTransition[]; +} +export const mixedNexusReload = (g: Game): Game => JSON.parse(JSON.stringify(g)) as Game; +export const mixedNexusPlayer = nativeHomeworldPlayer; +export const mixedNexusLeader = nativeHomeworldLeader; +export const mixedNexusQuote = quoteSingleNexusE1Battle; +export const mixedNexusShip = shipSingleNexusE1; +export const mixedNexusOpenBattle = openSingleNexusE1Battle; +export const mixedNexusRevealPlans = revealSingleNexusE1Plans; +export const mixedNexusClean = (g: Game): boolean => + !g.response && !g.phaseOpening && !g.decision && !g.pendingTreacheryDiscard && !g.truthtrance; + +/** Response queues have priority. Physical choices belong to the human, not to + * this scheduler. In particular a quoted casualty menu is not a default dial. */ +export function nextMixedNexusStep(g: Game): MixedNexusStep | null { + if (!g.response && !g.phaseOpening && g.decision) { + const d = g.decision; + if (['battleLosses', 'sukRescue', 'ixSubstitution', 'battleCards', 'faceDance', 'strongholdCopy'].includes(d.kind) || + d.kind === 'techToken' && !!g.battle) return null; + if (d.kind === 'richeseUnbid') return { actor: d.player, + action: { type: 'decision', event: g.richeseBidding!.event, keep: false } }; + } + return nextSingleNexusE1Step(g); +} +export function stepMixedNexus(g: Game, step: MixedNexusStep, actions?: MixedNexusStep[]): Game { + actions?.push(structuredClone(step)); + return withClassicDiscoveryNexusLottery(() => applyAction(mixedNexusReload(g), step.actor, step.action)); +} +export function advanceMixedNexus(g: Game, until: (g: Game) => boolean, actions?: MixedNexusStep[]): Game { + for (let n = 0; n < 3000; n++) { + if (until(g)) return g; + assert.notEqual(g.status, 'finished'); + const next = nextMixedNexusStep(g); + assert.ok(next, `Choose the original ${g.decision?.kind ?? 'sealed plan'} at ${g.turn}/${g.phase}.`); + g = stepMixedNexus(g, next, actions); + } + throw Error('Original mixed E1/E2 chronology did not reach its owned boundary.'); +} +export function settleMixedNexus(g: Game, actions?: MixedNexusStep[]): Game { + return advanceMixedNexus(g, q => mixedNexusClean(q) && !q.pendingShipment && + !q.pendingHomeworldShipment && !q.pendingChoamWorthless, actions); +} +function transition(g: Game, step: MixedNexusStep, actions: MixedNexusStep[]): MixedNexusTransition { + return { before: mixedNexusReload(g), step: structuredClone(step), after: stepMixedNexus(g, step, actions) }; +} +function permutation(source: readonly string[], desired: readonly string[]): number[] { + assert.deepEqual([...source].sort(), [...desired].sort()); + const current = [...source], rolls: number[] = []; + for (let i = current.length - 1; i > 0; i--) { + const j = current.indexOf(desired[i]); assert.ok(j >= 0 && j <= i); + rolls.push(Math.floor((j + 0.5) * 0x100000000 / (i + 1))); + [current[i], current[j]] = [current[j], current[i]]; + } + return rolls; +} +function lottery(rolls: readonly number[], operation: () => T): T { + const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); + const native = crypto.getRandomValues.bind(crypto); let cursor = 0; + crypto.getRandomValues = (array: V): V => { + if (array instanceof Uint32Array && array.length === 1) array[0] = rolls[cursor++] ?? 0xffffffff; + else Reflect.apply(native, crypto, [array]); + return array; + }; + try { return operation(); } + finally { + if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); + else Reflect.deleteProperty(crypto, 'getRandomValues'); + } +} +export function mixedNexusInventory(g: Game): void { + if (g.homeworlds) nativeHomeworldInventory(g); + else for (const p of g.players) { + assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((n, f) => n + f, 0), 20, p.faction); + if (p.elites) assert.equal(p.elites.reserves + p.elites.tanks + + Object.values(p.elites.forces).reduce((n, f) => n + f, 0), p.faction === 'ixians' ? 7 : p.faction === 'emperor' ? 5 : 3); + } + if (g.leaderSkills) validateLeaderSkills(g.leaderSkills, g.players); + if (g.discoveries) validateDiscoveryState(g.discoveries); + const cards = [...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand), ...(g.ixSetupCards ?? []), + ...(g.ixAuction?.cards ?? []), ...(g.richeseCache ?? []), ...(g.richeseRemoved ?? []), + ...(g.auction?.cards.slice(g.auction.index + (g.currentAuctionSale ? 1 : 0)) ?? [])]; + assert.deepEqual(cards.map(c => c.id).sort(), [...treacheryDeck(['ix', 'choam']), + ...(g.players.some(p => p.faction === 'richese') ? richeseCards() : [])].map(c => c.id).sort()); + validateNexusCards(g.nexusCards!.cards!, g.players); +} + +/** Original rules sources: Nexus closing, E1/E2 native powers, Homeworlds and + * Discovery composition; no companion is implied by another seated family. + * Assigned original training is valid input: pending offers alone continue. */ +export function createMixedNexusSetup(options: MixedNexusOptions = {}): MixedNexusSetup { + const roster: readonly FactionId[] = options.roster ?? ['ixians', 'tleilaxu', 'choam', 'richese', 'guild', 'fremen']; + let g = options.initial ? mixedNexusReload(options.initial) + : createGame('MIXEDNEXUS', newPlayer(roster[0], roster[0], roster[0]), options.advanced ?? true, ['ix', 'choam']); + if (!options.initial) for (const faction of roster.slice(1)) joinGame(g, newPlayer(faction, faction, faction)); + assert.deepEqual([...g.expansions].sort(), ['choam', 'ix']); + assert.equal(g.advanced, options.advanced ?? g.advanced); + assert.ok(g.players.length >= 2 && g.players.length <= 6); + assert.equal(new Set(g.players.map(p => p.faction)).size, g.players.length); + assert.ok(g.players.some(p => ['ixians', 'tleilaxu'].includes(p.faction)) && + g.players.some(p => ['choam', 'richese'].includes(p.faction))); + assert.ok(g.status === 'lobby' || g.status === 'setup' && g.turn === 1 && g.phase === 0, + 'Continue only an original lobby or its original setup, never a played save.'); + const skills = options.skills ?? (options.initial?.status === 'setup' ? !!g.leaderSkills : true); + const discovery = options.discovery ?? !!g.discoveryEnabled; + const modules = { homeworlds: options.homeworlds ?? !!g.homeworlds, + techTokens: options.tech ?? !!g.techTokens, strongholdCards: options.strongholds ?? !!g.strongholdCards }; + assert.ok(!modules.strongholdCards || g.advanced); + const actions: MixedNexusStep[] = [], seats: Partial> = {}; + for (const p of g.players) seats[p.faction] = p.id; + const skillOwner = seats[options.skillOwner ?? 'ixians'] ?? g.players[0].id; + const passives: LeaderSkillId[] = ['warmaster', 'sandmaster', 'killer-medic', 'prana-bindu-adept', 'swordmaster-of-ginaz'] + .filter(s => s !== (options.skill ?? 'suk-graduate')) as LeaderSkillId[]; + const selected = g.players.map(p => p.id === skillOwner ? options.skill ?? 'suk-graduate' : passives.shift()!); + if (g.status === 'lobby') { + for (const [type, enabled] of Object.entries(modules)) if (!!g[type as keyof typeof modules] !== enabled) + g = stepMixedNexus(g, { actor: g.host, action: { type, enabled } }, actions); + g.discoveryEnabled = discovery; g.nexusCards ??= { cards: null, phase: null }; + } else { + for (const [type, enabled] of Object.entries(modules)) assert.equal(!!g[type as keyof typeof modules], enabled); + assert.equal(!!g.discoveryEnabled, discovery); assert.equal(!!g.leaderSkills, skills); + assert.ok(g.nexusCards?.cards, 'A supplied original deal cannot acquire a new deck.'); + } + const initial = mixedNexusReload(g); + if (g.status === 'lobby') { + for (const p of g.players) if (!p.ready) g = stepMixedNexus(g, { actor: p.id, action: { type: 'ready' } }, actions); + const source = LEADER_SKILL_CARDS.map(c => c.id), rest = source.filter(s => !selected.includes(s)); + const skillOrder = selected.flatMap(s => [s, rest.shift()!]).concat(rest); + const deck = treacheryDeck(g.expansions), remaining = [...deck], prefix = []; + const dealOrder = seats.ixians ? [mixedNexusPlayer(g, seats.ixians), ...g.players.filter(p => p.id !== seats.ixians)] : g.players; + for (const p of dealOrder) { + const kind = p.faction === 'choam' ? options.fuelKind ?? 'special' : 'worthless'; + const at = remaining.findIndex(c => c.kind === kind && (kind !== 'special' || c.effect !== 'karama')); + assert.ok(at >= 0); prefix.push(remaining.splice(at, 1)[0]); + } + const rolls = [...(skills ? permutation(source, skillOrder) : []), + ...(discovery ? Array(7).fill(0xffffffff) : []), + ...Array(NEXUS_FACTIONS.length - 1).fill(0xffffffff), + ...permutation(deck.map(c => c.id), [...prefix, ...remaining].map(c => c.id))]; + const initialize = () => skills ? initializeLeaderSkillsGameForAudit(g) : initializeNexusGameForAudit(g); + g = options.initial ? initialize() : lottery(rolls, initialize); + } + const setup = mixedNexusReload(g); let offered: Game | null = null, ixOffer: MixedNexusTransition | null = null; + const trainers: Record = {}; + for (const a of g.leaderSkills?.assignments ?? []) trainers[a.owner] = a.leader; + const target = seats.ixians ? trainers[seats.ixians] ?? mixedNexusPlayer(g, seats.ixians).leaders.slice() + .sort((a, b) => b.strength - a.strength)[0].id : null; + let dancerLeader = target; + for (let n = 0; g.status === 'setup' && n < 250; n++) { + let next: MixedNexusStep; + if (g.decision?.kind === 'ixSetup') { + const offer = viewGame(g, g.decision.player).ixTechnology!.setup!; + const card = offer.find(c => c.kind === 'worthless') ?? offer[0]; assert.ok(card); + ixOffer = transition(g, { actor: g.decision.player, action: { type: 'decision', card: card.id } }, actions); + g = ixOffer.after; continue; + } + if (g.setupStage === 'leaderSkills' && !g.decision) { + offered ??= mixedNexusReload(g); + const actor = Object.keys(g.leaderSkills!.offers)[0], view = viewGame(g, actor).leaderSkills!, offer = view.offer!; + const wanted = selected[g.players.findIndex(p => p.id === actor)]; + const skill = options.initial ? offer.cards.find(s => !view.unavailableSkills?.[s]) + : offer.cards.find(s => s === wanted && !view.unavailableSkills?.[s]); assert.ok(skill); + const eligible = new Set(view.eligibleLeaders.map(l => l.id)); + const leader = mixedNexusPlayer(g, actor).leaders.filter(l => eligible.has(l.id)).sort((a, b) => b.strength - a.strength)[0]; + assert.ok(leader); trainers[actor] = leader.id; + if (actor === seats.ixians) dancerLeader = leader.id; + next = { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: leader.id } }; + } else { + const candidate = nextMixedNexusStep(g); assert.ok(candidate); next = candidate; + if (next.action.type === 'traitor') { + const choices = mixedNexusPlayer(g, next.actor).traitorChoices; + next.action.leader = choices.find(id => id !== dancerLeader) ?? choices[0]; + } + if (next.action.type === 'fremenSetup') next.action.placements = { sietch_tabr: 0, false_wall_south: 10, false_wall_west: 0 }; + } + if (!options.initial && seats.tleilaxu && dancerLeader && g.setupStage === 'forces' && next.action.type === 'fremenSetup') { + const held = new Set(g.players.flatMap(p => p.traitors)), source = traitorDeck(g.players, true).filter(id => !held.has(id)); + assert.ok(source.includes(dancerLeader)); actions.push(structuredClone(next)); + g = lottery(permutation(source, [dancerLeader, ...source.filter(id => id !== dancerLeader)]), + () => applyAction(mixedNexusReload(g), next.actor, next.action)); + } else g = stepMixedNexus(g, next, actions); + } + assert.equal(g.status, 'playing'); mixedNexusInventory(g); + return { initial, setup, offered, ixOffer, afterSetup: mixedNexusReload(g), trainers, dancerLeader, seats, actions }; +} + +/** Only original unplayed source-order permutations and unused lotteries are + * selected. Every army, invoice, token, alliance and reward is action-produced. + * Two seats have no legal closing qualification; their endpoint is native setup. */ +export function createMixedNexusProgramme(options: MixedNexusOptions = {}): MixedNexusProgramme { + const s = createMixedNexusSetup(options), actions = s.actions; + let g = mixedNexusReload(s.afterSetup); + const result: MixedNexusProgramme = { ...s, game: g, firstArrival: null, firstMarker: null, reveal: null, + entry: null, vote: null, ride: null, firstMentat: null, alliance: null, drawing: null, draws: [] }; + if (g.players.length === 2) return result; + const guild = s.seats.guild, fremen = s.seats.fremen; + const discovery = !!g.discoveries; + const collector = s.seats.ixians ?? guild ?? g.players[0].id; + assert.ok(!discovery || guild && fremen, 'Maker programme uses original Guild and Fremen seats.'); + frontNativeHomeworldSpice(g, c => 'territory' in c && (discovery ? c.discovery === 'discovery-hagga-basin' : !c.discovery && c.territory === 'hagga_basin'), 0); + if (g.advanced) frontNativeHomeworldSpice(g, c => 'territory' in c && !c.discovery && c.territory === 'broken_land', 1); + const offset = g.advanced ? 2 : 1; + frontNativeHomeworldSpice(g, c => 'worm' in c && (discovery ? !!c.greatMaker : !c.greatMaker && !c.suppressed), offset); + frontNativeHomeworldSpice(g, c => 'territory' in c && !c.discovery && c.territory === 'rock_outcroppings', offset + 1); + if (g.advanced) frontNativeHomeworldSpice(g, c => 'territory' in c && !c.discovery && c.territory === 'oh_gap', offset + 2); + g = advanceMixedNexus(g, q => q.phase === 1 && mixedNexusClean(q), actions); + if (discovery) { + for (let n = 0; !g.discoveries!.tokens.some(t => t.face === 'shrine' && t.status === 'placed') && n < 100; n++) { + const next = nextMixedNexusStep(g); assert.ok(next); actions.push(structuredClone(next)); + g = withClassicDiscoveryNexusToken(g, 'shrine', () => applyAction(mixedNexusReload(g), next.actor, next.action)); + } + assert.ok(g.discoveries!.tokens.some(t => t.face === 'shrine' && t.status === 'placed')); + } + const needed = new Set([...(discovery ? [collector] : []), ...(s.seats.richese ? [s.seats.richese] : [])]); + while (needed.size) { + g = advanceMixedNexus(g, q => q.phase === 5 && mixedNexusClean(q) && needed.has(q.active!), actions); + const actor = g.active!; + if (actor === s.seats.richese) { + const p = mixedNexusPlayer(g, actor), token = p.noField!.tokens.find(t => t.value === 0)!; + result.firstMarker = transition(g, { actor, action: { type: 'ship', territory: 'habbanya_ridge_sietch', + sector: territory('habbanya_ridge_sietch').sectors[0], noField: token.id, event: p.noFieldEvent, allyPayment: 0 } }, actions); + g = settleMixedNexus(result.firstMarker.after, actions); result.firstMarker.after = mixedNexusReload(g); + g = stepMixedNexus(g, { actor, action: { type: 'revealNoField', token: token.id, event: mixedNexusPlayer(g, actor).noFieldEvent } }, actions); + } else { + const placement = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin']; + const elite = mixedNexusPlayer(g, actor).faction === 'ixians' ? 1 : 0; + const sources = g.homeworlds ? nativeShipmentSources(viewGame(g, actor), 3, elite) : undefined; + if (g.homeworlds) assert.ok(sources); + result.firstArrival = transition(g, { actor, action: { type: 'ship', territory: placement.territory, + sector: placement.sector, amount: 3, elite, allyPayment: 0, + ...(sources ? { homeworldSources: sources } : {}) } }, actions); + g = settleMixedNexus(result.firstArrival.after, actions); result.firstArrival.after = mixedNexusReload(g); + } + needed.delete(actor); + if (needed.size) g = stepMixedNexus(g, { actor, action: { type: 'endMovement' } }, actions); + } + if (discovery) { + g = advanceMixedNexus(g, q => q.phase === 7 && mixedNexusClean(q), actions); + const token = g.discoveries!.tokens.find(t => t.face === 'shrine' && t.status === 'placed')!; + g = stepMixedNexus(g, { actor: collector, action: { type: 'discovery', token: token.id, reveal: false } }, actions); + result.reveal = transition(g, { actor: collector, action: { type: 'discovery', token: token.id, reveal: true } }, actions); g = result.reveal.after; + } + g = advanceMixedNexus(g, q => q.phase === 8 && mixedNexusClean(q), actions); + while (g.turn === 1) { + const next = nextMixedNexusStep(g); assert.ok(next); const t = transition(g, next, actions); g = t.after; + if (g.turn === 2) result.firstMentat = t; + } + if (discovery) { + g = advanceMixedNexus(g, q => q.decision?.kind === 'discoveryEntry' && q.decision.player === collector, actions); + const view = viewGame(g, collector), sources = view.discoveryEntry!.sources; + const action = discoveryEntryMoveAction(view, mixedNexusPlayer(g, collector).faction === 'ixians' + ? sources.map(source => ({ ...source, normal: Math.min(source.normal, 1), elite: Math.min(source.elite, 1) })) : sources); assert.ok(action); + result.entry = transition(g, { actor: collector, action }, actions); g = result.entry.after; + g = advanceMixedNexus(g, q => q.decision?.kind === 'greatMakerVote', actions); result.vote = mixedNexusReload(g); + while (g.decision?.kind === 'greatMakerVote') g = stepMixedNexus(g, + { actor: g.decision.player, action: { type: 'decision', event: g.decision.event, yes: true } }, actions); + assert.ok(g.decision?.kind === 'greatMakerRide' && g.decision.player === fremen); + const actionRide = greatMakerRideAction(viewGame(g, fremen!), 'polar_sink', 0, 2, g.advanced ? 1 : 0); assert.ok(actionRide); + result.ride = transition(g, { actor: fremen!, action: actionRide }, actions); g = result.ride.after; + } + g = advanceMixedNexus(g, q => !!q.nexus && !q.spiceWindow && !q.spiceResolution && mixedNexusClean(q), actions); + const pair = guild && fremen ? [guild, fremen] : g.players.slice(-2).map(p => p.id); + for (const [actor, target] of [[pair[0], pair[1]], [pair[1], pair[0]]]) + g = stepMixedNexus(g, { actor, action: { type: 'alliance', target } }, actions); + result.alliance = mixedNexusReload(g); + g = advanceMixedNexus(g, q => q.nexusCards?.phase?.stage === 'drawing' && mixedNexusClean(q), actions); + result.drawing = mixedNexusReload(g); + for (const actor of g.nexusCards!.phase!.eligible) { + const face = mixedNexusPlayer(g, actor).faction, cards = g.nexusCards!.cards!; + const at = cards.deck.indexOf(face); assert.ok(at >= 0); cards.deck.unshift(cards.deck.splice(at, 1)[0]); + const draw = transition(g, { actor, action: { type: 'nexusCardChoice', turn: g.turn, + card: cards.hands[actor] ?? null, choice: 'draw', ownRedraws: 0 } }, actions); + result.draws.push(draw); g = draw.after; + } + g = advanceMixedNexus(g, q => q.phase === 5 && mixedNexusClean(q), actions); + mixedNexusInventory(g); result.game = g; return result; +} diff --git a/tests/fixture-paired-e3-nexus-modules.ts b/tests/fixture-paired-e3-nexus-modules.ts new file mode 100644 index 00000000..37a327b7 --- /dev/null +++ b/tests/fixture-paired-e3-nexus-modules.ts @@ -0,0 +1,376 @@ +import assert from 'node:assert/strict'; +import { applyAction, createGame, initializeLeaderSkillsGameForAudit, initializePairedNexusGameForAudit, + joinGame, newPlayer, viewGame } from '../game/engine'; +import type { Action, Game, Player } from '../game/engine'; +import type { FactionId } from '../game/catalog'; +import type { Difficulty } from '../game/bot-profiles'; +import { botActions } from '../game/bots'; +import { leaders, spiceDeck, treacheryDeck } from '../game/cards'; +import { DUKE_VIDAL_ID } from '../game/duke-vidal'; +import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards'; +import type { LeaderSkillId } from '../game/leader-skill-cards'; +import { validateLeaderSkills } from '../game/leader-skills'; +import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards'; +import { traitorDeck } from '../game/traitors'; +import { chooseEcazLoyalty } from '../game/ecaz-loyalty'; +import { TERROR_KINDS } from '../game/moritani-terror'; +import { validateAmbassadors } from '../game/ecaz-ambassadors'; +import { DISCOVERY_SPICE_CARDS, validateDiscoveryState } from '../game/discoveries'; +import { greatMakerRideAction } from '../game/great-maker-options'; +import { nativeShipmentSources } from '../game/homeworld-options'; +import { homeworldShipmentChoice } from '../game/homeworld-shipment-options'; +import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game'; +import { homeworldForceGroups } from '../game/homeworld-custody'; +import { homeworldCombatLocation } from '../game/homeworld-combat'; +import { quoteBattleResolution } from '../game/battle-resolution-quote'; +import type { BattleResolutionQuote, ResolutionCombatant } from '../game/battle-resolution-quote'; +import { nextE3NexusEcazStep } from './fixture-e3-nexus-ecaz'; +import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus'; + +export interface PairedE3NexusStep { actor: string; action: Action } +export interface PairedE3NexusOptions { + initial?: Game; + roster?: readonly FactionId[]; + advanced?: boolean; + skills?: boolean; + homeworlds?: boolean; + discovery?: boolean; + tech?: boolean; + strongholds?: boolean; + assassinationFaction?: 'guild' | 'ecaz'; +} +export interface PairedE3NexusTransition { before: Game; step: PairedE3NexusStep; after: Game } +export interface PairedE3NexusSetup { + initial: Game; offered: Game; afterSetup: Game; + ecaz: string; moritani: string; others: string[]; actions: PairedE3NexusStep[]; +} +export interface PairedE3NexusProgramme extends PairedE3NexusSetup { + game: Game; alliance: Game | null; drawing: Game | null; + draws: PairedE3NexusTransition[]; maker: Game | null; + claim: PairedE3NexusTransition | null; firstMentat: PairedE3NexusTransition | null; +} +export interface PairedE3NexusBattle { + game: Game; beforeBattle: Game; location: string; winner: string; loser: string; + arrivals: PairedE3NexusTransition[]; plans: PairedE3NexusStep[]; +} +export const pairedE3NexusReload = (g: Game): Game => JSON.parse(JSON.stringify(g)) as Game; +export const pairedE3NexusClean = (g: Game): boolean => + !g.response && !g.phaseOpening && !g.decision && !g.pendingTreacheryDiscard && !g.truthtrance; +export function pairedE3NexusPlayer(g: Game, actor: string): Player { + const p = g.players.find(p => p.id === actor); assert.ok(p); return p; +} +export function pairedE3NexusPolicy(g: Game, actor: string, difficulty: Difficulty): Action[] { + const saved = pairedE3NexusReload(g); pairedE3NexusPlayer(saved, actor).bot = difficulty; + return botActions(viewGame(pairedE3NexusReload(saved), actor)); +} +/** Original response queues precede owned native choices. A human selects the + * coalition lead, posture, assassination, Terror, physical losses and rewards. */ +export function nextPairedE3NexusStep(g: Game): PairedE3NexusStep | null { + if (!g.response && !g.phaseOpening && !g.pendingTreacheryDiscard && g.setupStage === 'leaderSkills') return null; + if (!g.response && !g.phaseOpening && !g.pendingTreacheryDiscard && g.decision) { + if (['ecazBattleLead', 'leaderSkillVisibility', 'moritaniPlacement', 'moritaniAssassinate', + 'moritaniTerror', 'ecazPlacement', 'ecazAmbassador', 'battleLosses', 'battleCards', + 'techToken', 'sukRescue', 'strongholdCopy', 'discoveryEntry', 'greatMakerVote', + 'greatMakerRide', 'homeworldDefense', 'homeworldRevivalDeployment', 'moritaniRetention'].includes(g.decision.kind)) return null; + if (g.decision.kind === 'moritaniSetup') { + const action = pairedE3NexusPolicy(g, g.decision.player, 'Easy')[0]; assert.ok(action); + return { actor: g.decision.player, action }; + } + } + return nextE3NexusEcazStep(g); +} +export function stepPairedE3Nexus(g: Game, step: PairedE3NexusStep, actions?: PairedE3NexusStep[]): Game { + actions?.push(structuredClone(step)); + return withClassicDiscoveryNexusLottery(() => applyAction(pairedE3NexusReload(g), step.actor, step.action)); +} +export function advancePairedE3Nexus(g: Game, until: (g: Game) => boolean, + actions?: PairedE3NexusStep[], choose?: (g: Game) => PairedE3NexusStep | null): Game { + for (let n = 0; n < 3200; n++) { + if (until(g)) return g; + assert.notEqual(g.status, 'finished'); + const next = nextPairedE3NexusStep(g) ?? choose?.(g); + assert.ok(next, `The owner must choose ${g.decision?.kind ?? 'a sealed plan'} at ${g.turn}/${g.phase}`); + g = stepPairedE3Nexus(g, next, actions); + } + throw Error('Original paired E3 programme did not reach its boundary'); +} +/** Explicit quiet programme choices: notably decline Grumman so supply remains + * available. Battle choices are not supplied by this traversal. */ +export function quietPairedE3NexusChoice(g: Game): PairedE3NexusStep | null { + const d = g.decision; if (!d || g.response || g.phaseOpening) return null; + if (['moritaniPlacement', 'ecazPlacement', 'ecazAmbassador', 'moritaniAssassinate', + 'homeworldRevivalDeployment'].includes(d.kind)) + return { actor: d.player, action: { type: 'decision', decline: true, + ...('event' in d ? { event: d.event } : {}) } }; + if (d.kind === 'leaderSkillVisibility') + return { actor: d.player, action: { type: 'leaderSkillVisibility', event: d.event, hide: false } }; + if (d.kind === 'discoveryEntry' || d.kind === 'greatMakerRide') + return { actor: d.player, action: { type: 'decision', event: d.event, accept: false } }; + if (d.kind === 'greatMakerVote') return { actor: d.player, action: { type: 'decision', event: d.event, yes: true } }; + if (d.kind === 'homeworldDefense') return { actor: d.player, action: { type: 'decision', event: d.event, use: false } }; + if (d.kind === 'moritaniTerror') return { actor: d.player, action: { type: 'decision', decline: true } }; + return null; +} +function permutation(source: readonly string[], desired: readonly string[]): number[] { + assert.deepEqual([...source].sort(), [...desired].sort()); + const working = [...source], rolls: number[] = []; + for (let i = working.length - 1; i > 0; i--) { + const j = working.indexOf(desired[i]); assert.ok(j >= 0 && j <= i); + rolls.push(Math.floor((j + 0.5) * 0x100000000 / (i + 1))); + [working[i], working[j]] = [working[j], working[i]]; + } + return rolls; +} +function lottery(rolls: readonly number[], operation: () => T): T { + const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); + const native = crypto.getRandomValues.bind(crypto); let cursor = 0; + crypto.getRandomValues = (array: V): V => { + if (array instanceof Uint32Array && array.length === 1) array[0] = rolls[cursor++] ?? 0xffffffff; + else Reflect.apply(native, crypto, [array]); + return array; + }; + try { return operation(); } + finally { + if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); + else Reflect.deleteProperty(crypto, 'getRandomValues'); + } +} +function selectedSkills(g: Game): LeaderSkillId[] { + const preferred: Partial> = { ecaz: 'warmaster', moritani: 'master-of-assassins', + guild: 'suk-graduate', emperor: 'swordmaster-of-ginaz', atreides: 'bureaucrat', + fremen: 'killer-medic', beneGesserit: 'prana-bindu-adept', harkonnen: 'smuggler' }; + return g.players.map(p => preferred[p.faction]!); +} +function traitorRolls(g: Game, faction: 'guild' | 'ecaz' = 'guild'): number[] { + const original = traitorDeck(g.players), loyal = g.advanced ? chooseEcazLoyalty(original, 0xffffffff / 0x100000000) : null; + const source = original.filter(c => c !== loyal), desired = [...source]; + const target = faction === 'ecaz' ? source.find(card => leaders('ecaz').some(l => l.id === card)) + : leaders('guild').find(l => l.name === 'Master Bewt')?.id; + if (target && g.players.some(p => p.faction === faction)) { + const destination = g.players.findIndex(p => p.faction === 'moritani') * 4; + const at = desired.indexOf(target); [desired[at], desired[destination]] = [desired[destination], desired[at]]; + } + return [...(g.advanced ? [0xffffffff] : []), ...permutation(source, desired)]; +} +/** Determinism selects only original undealt lotteries. Supplied lobby entropy + * and partially assigned original setup are authoritative and never redealt. */ +export function createPairedE3NexusSetup(options: PairedE3NexusOptions = {}): PairedE3NexusSetup { + const roster = options.roster ?? options.initial?.players.map(p => p.faction) ?? ['ecaz', 'moritani', 'guild', 'emperor']; + const advanced = options.advanced ?? options.initial?.advanced ?? true; + let g = options.initial ? pairedE3NexusReload(options.initial) + : createGame('PAIREDE3NEXUS', newPlayer(roster[0], roster[0], roster[0]), advanced, ['ecaz']); + if (!options.initial) for (const faction of roster.slice(1)) joinGame(g, newPlayer(faction, faction, faction)); + assert.equal(g.advanced, advanced); assert.deepEqual(g.expansions, ['ecaz']); + assert.deepEqual(g.players.map(p => p.faction), roster); + assert.ok(g.players.length >= 2 && g.players.length <= 6 && new Set(roster).size === roster.length); + assert.ok(roster.includes('ecaz') && roster.includes('moritani')); + assert.ok(roster.every(f => ['ecaz', 'moritani', 'guild', 'emperor', 'atreides', 'fremen', 'beneGesserit', + ...(advanced ? [] : ['harkonnen'])].includes(f))); + const skills = options.skills ?? (options.initial ? !!g.leaderSkills : false); + const discovery = options.discovery ?? !!g.discoveryEnabled; + const modules = { homeworlds: options.homeworlds ?? !!g.homeworlds, + techTokens: options.tech ?? !!g.techTokens, strongholdCards: options.strongholds ?? !!g.strongholdCards }; + assert.ok(!modules.techTokens || g.players.length >= 3); assert.ok(!modules.strongholdCards || advanced); + assert.ok(g.status === 'lobby' || g.status === 'setup' && g.turn === 1 && g.phase === 0, + 'Continue only an original lobby or original first-turn setup'); + assert.ok(!g.ecazTreachery); + const initial = pairedE3NexusReload(g), actions: PairedE3NexusStep[] = []; + if (g.status === 'lobby') { + for (const [type, enabled] of Object.entries(modules)) if (!!g[type as keyof typeof modules] !== enabled) + g = stepPairedE3Nexus(g, { actor: g.host, action: { type, enabled } }, actions); + g.discoveryEnabled = discovery; g.nexusCards ??= { cards: null, phase: null }; + for (const p of g.players) if (!p.ready) g = stepPairedE3Nexus(g, { actor: p.id, action: { type: 'ready' } }, actions); + if (options.initial) g = skills ? initializeLeaderSkillsGameForAudit(g) : initializePairedNexusGameForAudit(g); + else { + const sourceSkills = LEADER_SKILL_CARDS.map(c => c.id), selected = selectedSkills(g); + const rest = sourceSkills.filter(c => !selected.includes(c)); + const skillOrder = selected.flatMap(c => [c, rest.shift()!]).concat(rest); + const cards = treacheryDeck(['ecaz']), remaining = [...cards]; + const prefix = g.players.flatMap(p => Array.from({ length: p.faction === 'harkonnen' ? 2 : 1 }, () => { + const worthless = remaining.findIndex(c => c.kind === 'worthless'); + const at = worthless >= 0 ? worthless : remaining.findIndex(c => c.kind === 'shield'); assert.ok(at >= 0); + return remaining.splice(at, 1)[0].id; + })); + const rolls = [...(skills ? permutation(sourceSkills, skillOrder) : []), + ...(discovery ? Array(7).fill(0xffffffff) : []), + ...Array(NEXUS_FACTIONS.length - 1).fill(0xffffffff), + ...permutation(cards.map(c => c.id), [...prefix, ...remaining.map(c => c.id)]), + ...Array(spiceDeck().length + (discovery ? DISCOVERY_SPICE_CARDS.length + 1 : 0) - 1).fill(0xffffffff), + ...(!skills && !roster.includes('beneGesserit') ? traitorRolls(g, options.assassinationFaction) : [])]; + g = lottery(rolls, () => skills ? initializeLeaderSkillsGameForAudit(g) : initializePairedNexusGameForAudit(g)); + } + } + assert.equal(!!g.leaderSkills, skills); assert.equal(!!g.discoveries, discovery); + for (const [type, enabled] of Object.entries(modules)) assert.equal(!!g[type as keyof typeof modules], enabled); + assert.ok(g.nexusCards?.cards); + const offered = pairedE3NexusReload(g), selected = selectedSkills(g); + for (let n = 0; g.status === 'setup' && n < 300; n++) { + let next: PairedE3NexusStep; + if (g.setupStage === 'leaderSkills') { + const actor = Object.keys(g.leaderSkills!.offers)[0], own = viewGame(g, actor).leaderSkills!; + const skill = options.initial ? own.offer!.cards.find(c => !own.unavailableSkills?.[c]) : selected[g.players.findIndex(p => p.id === actor)]; + const eligible = own.eligibleLeaders.map(l => pairedE3NexusPlayer(g, actor).leaders.find(d => d.id === l.id)!); + const leader = pairedE3NexusPlayer(g, actor).faction === 'guild' + ? eligible.find(l => l.name === 'Master Bewt') ?? eligible[0] : eligible.sort((a, b) => b.strength - a.strength)[0]; + assert.ok(skill && leader && own.offer!.cards.includes(skill) && !own.unavailableSkills?.[skill]); + next = { actor, action: { type: 'leaderSkill', event: own.offer!.event, skill, leader: leader.id } }; + if (!options.initial && Object.keys(g.leaderSkills!.offers).length === 1) { + actions.push(structuredClone(next)); + g = lottery([...Array(g.leaderSkills!.deck.length).fill(0xffffffff), ...traitorRolls(g, options.assassinationFaction)], + () => applyAction(pairedE3NexusReload(g), next.actor, next.action)); continue; + } + } else { + const proposed = nextPairedE3NexusStep(g) ?? quietPairedE3NexusChoice(g); assert.ok(proposed); next = proposed; + if (next.action.type === 'traitor' && pairedE3NexusPlayer(g, next.actor).faction === 'moritani') { + const choices = pairedE3NexusPlayer(g, next.actor).traitorChoices; + const target = options.assassinationFaction === 'ecaz' + ? choices.find(card => leaders('ecaz').some(l => l.id === card)) + : leaders('guild').find(l => l.name === 'Master Bewt')!.id; + next.action.leader = target && choices.includes(target) ? target : choices[0]; + } + if (next.action.type === 'fremenSetup') next.action.placements = { sietch_tabr: 0, false_wall_south: 10, false_wall_west: 0 }; + } + if (options.initial) { + actions.push(structuredClone(next)); + g = applyAction(pairedE3NexusReload(g), next.actor, next.action); + } else g = stepPairedE3Nexus(g, next, actions); + } + assert.equal(g.status, 'playing'); + const ecaz = g.players.find(p => p.faction === 'ecaz')!.id, moritani = g.players.find(p => p.faction === 'moritani')!.id; + pairedE3NexusInventory(g); + return { initial, offered, afterSetup: g, ecaz, moritani, others: g.players.filter(p => ![ecaz, moritani].includes(p.id)).map(p => p.id), actions }; +} +function frontSpice(g: Game, match: (c: Game['spiceDeck'][number]) => boolean, position: number): void { + const at = g.spiceDeck.findIndex((c, i) => i >= position && match(c)); assert.ok(at >= position); + g.spiceDeck.splice(position, 0, g.spiceDeck.splice(at, 1)[0]); +} +/** Both natives stay unallied while two real other seats ally. Two/three-seat + * programmes return native setup: there cannot be two unallied native draws. */ +export function createPairedE3NexusProgramme(options: PairedE3NexusOptions = {}): PairedE3NexusProgramme { + const f = createPairedE3NexusSetup(options); + let g = pairedE3NexusReload(f.afterSetup); + if (f.others.length < 2) return { ...f, game: g, alliance: null, drawing: null, draws: [], maker: null, claim: null, firstMentat: null }; + for (let i = 0; i < (g.advanced ? 2 : 1); i++) + frontSpice(g, c => 'territory' in c && !c.discovery && !['arrakeen', 'tueks_sietch'].includes(c.territory), i); + g = advancePairedE3Nexus(g, s => s.phase === 5 && s.active === f.ecaz && pairedE3NexusClean(s), f.actions, quietPairedE3NexusChoice); + const claim = shipPairedE3Nexus(g, f.ecaz, 'sietch_tabr:14', 1, f.actions); g = claim.after; + let firstMentat: PairedE3NexusTransition | null = null; + for (let n = 0; g.turn === 1 && n < 600; n++) { + const before = pairedE3NexusReload(g), next = nextPairedE3NexusStep(g) ?? quietPairedE3NexusChoice(g); assert.ok(next); + g = stepPairedE3Nexus(g, next, f.actions); + if (g.turn === 2) firstMentat = { before, step: next, after: pairedE3NexusReload(g) }; + } + assert.ok(firstMentat); + g = advancePairedE3Nexus(g, s => s.turn === 2 && s.phase === 1 && pairedE3NexusClean(s), f.actions, quietPairedE3NexusChoice); + frontSpice(g, c => 'worm' in c && (g.discoveries ? !!c.greatMaker : !c.greatMaker && !c.suppressed), 0); + for (let i = 1; i <= (g.advanced ? 2 : 1); i++) frontSpice(g, c => 'territory' in c && !c.discovery, i); + let maker: Game | null = null; + g = advancePairedE3Nexus(g, s => !!s.nexus && !s.spiceWindow && !s.spiceResolution && pairedE3NexusClean(s), f.actions, s => { + if (s.decision?.kind === 'greatMakerVote') maker ??= pairedE3NexusReload(s); + if (s.decision?.kind === 'greatMakerRide') { + const action = greatMakerRideAction(viewGame(s, s.decision.player), 'polar_sink', 0, 2, s.advanced ? 1 : 0); + assert.ok(action); return { actor: s.decision.player, action }; + } + return quietPairedE3NexusChoice(s); + }); + for (const [actor, target] of [[f.others[0], f.others[1]], [f.others[1], f.others[0]]]) + g = stepPairedE3Nexus(g, { actor, action: { type: 'alliance', target } }, f.actions); + const alliance = pairedE3NexusReload(g); + g = advancePairedE3Nexus(g, s => s.nexusCards?.phase?.stage === 'drawing' && pairedE3NexusClean(s), f.actions, quietPairedE3NexusChoice); + const cards = g.nexusCards!.cards!; + cards.deck = ['ecaz', 'moritani', ...cards.deck.filter(c => c !== 'ecaz' && c !== 'moritani')]; + const drawing = pairedE3NexusReload(g), draws: PairedE3NexusTransition[] = []; + for (const actor of [f.ecaz, f.moritani]) { + const before = pairedE3NexusReload(g), step: PairedE3NexusStep = { actor, + action: { type: 'nexusCardChoice', turn: g.turn, card: cards.hands[actor] ?? null, choice: 'draw', ownRedraws: 0 } }; + g = stepPairedE3Nexus(g, step, f.actions); draws.push({ before, step, after: pairedE3NexusReload(g) }); + } + g = advancePairedE3Nexus(g, s => s.phase === 5 && pairedE3NexusClean(s), f.actions, quietPairedE3NexusChoice); + pairedE3NexusInventory(g); return { ...f, game: g, alliance, drawing, draws, maker, claim, firstMentat }; +} +export function shipPairedE3Nexus(g: Game, actor: string, location: string, amount: number, + actions?: PairedE3NexusStep[]): PairedE3NexusTransition { + assert.ok(g.phase === 5 && g.active === actor && pairedE3NexusClean(g)); + const sources = g.homeworlds ? nativeShipmentSources(viewGame(g, actor), amount, 0) : undefined; + if (g.homeworlds) assert.ok(sources); + let action: Action; + if (location.startsWith('homeworld:')) { + assert.ok(sources); const choice = homeworldShipmentChoice(viewGame(g, actor), location, + Object.fromEntries(Object.entries(sources).filter(([, f]) => f.normal + f.elite > 0))); + assert.ok(choice.action, choice.blocked ?? undefined); action = choice.action; + } else { + const [site, sector] = location.split(':'); + action = { type: 'ship', territory: site, sector: Number(sector), amount, elite: 0, allyPayment: 0, + ...(sources ? { homeworldSources: sources } : {}) }; + } + const before = pairedE3NexusReload(g), step = { actor, action }; + g = stepPairedE3Nexus(g, step, actions); + g = advancePairedE3Nexus(g, s => pairedE3NexusClean(s) && !s.pendingShipment && !s.pendingHomeworldShipment, + actions, quietPairedE3NexusChoice); + return { before, step, after: g }; +} +export function openPairedE3NexusBattle(g: Game, location: string, actions?: PairedE3NexusStep[]): Game { + g = advancePairedE3Nexus(g, s => s.phase === 6 && pairedE3NexusClean(s), actions, quietPairedE3NexusChoice); + const choice = viewGame(g, g.active!).battleChoices.find(c => c.territory === location); assert.ok(choice); + return stepPairedE3Nexus(g, { actor: choice.chooser, action: { type: 'chooseBattle', territory: location, + target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } }, actions); +} +export function revealPairedE3Nexus(g: Game, plans: readonly PairedE3NexusStep[], actions?: PairedE3NexusStep[]): Game { + g = advancePairedE3Nexus(g, s => !!s.battle && pairedE3NexusClean(s) && !s.battle.preparation && s.battle.preLeader?.closed !== false, + actions, quietPairedE3NexusChoice); + for (const plan of plans) { + g = stepPairedE3Nexus(g, plan, actions); + g = advancePairedE3Nexus(g, s => !s.response && !s.phaseOpening && s.decision?.kind !== 'fullPlanOffer', actions, quietPairedE3NexusChoice); + } + assert.ok(g.battle?.revealed); return g; +} +export function quotePairedE3NexusBattle(g: Game): BattleResolutionQuote { + const b = g.battle!, publicBattle = viewGame(g, g.host).battle!; assert.ok(b?.revealed); + const side = (actor: string): ResolutionCombatant => { + const p = pairedE3NexusPlayer(g, actor), view = viewGame(g, actor).battle!; assert.ok(view.ownForces); + return { id: actor, faction: p.faction, ally: p.ally, spice: p.spice, hand: p.hand, plan: b.plans[actor], + leader: p.leaders.find(l => l.id === b.plans[actor].leader) ?? (g.dukeVidal?.controller === actor ? g.dukeVidal.leader : undefined), + forces: view.ownForces, stronghold: view.strongholdEffects[actor], + leaderSkills: g.leaderSkills?.assignments.filter(a => a.owner === actor).map(a => ({ skill: a.skill, leader: a.leader, + faceUp: !b.leaderSkillHidden?.[actor], captured: false })) }; + }; + const context = homeworldContext(g), home = b.territory.startsWith('homeworld:') + ? homeworldCombatLocation({ ...context, players: context.players.map(p => ({ ...p, ally: pairedE3NexusPlayer(g, p.id).ally })), order: g.order }, g.homeworlds!.custody!, b.territory) : null; + return quoteBattleResolution({ advanced: g.advanced, typedCasualties: !!g.homeworlds, turn: g.turn, + territory: b.territory, aggressor: publicBattle.aggressor, + ...(publicBattle.ecazOccupy?.profile ? { ecazOccupy: publicBattle.ecazOccupy.profile } : {}), + ...(home ? { homeworld: { native: home.native, card: home.card, side: home.side, nativeForces: home.forces[home.native] } } : {}), + attacker: side(b.attacker), defender: side(b.defender), voters: publicBattle.traitorVoters.map(actor => ({ id: actor, + beneficiary: [b.attacker, b.defender].includes(actor) ? actor : pairedE3NexusPlayer(g, actor).ally!, + called: b.traitorCalls[actor] ?? false, traitors: pairedE3NexusPlayer(g, actor).traitors })), + participants: g.players, physicalCards: [...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand)], + pendingAuditorPresent: false, pendingRetentionPresent: false, nexusMoritani: true }); +} +export function pairedE3NexusInventory(g: Game): void { + const nx = g.nexusCards!.cards!; validateNexusCards(nx, g.players); + assert.deepEqual([...nx.deck, ...nx.discard, ...Object.values(nx.hands).filter(c => c !== null)].sort(), [...NEXUS_FACTIONS].sort()); + const auction = g.auction?.cards.slice(g.auction.index + (g.currentAuctionSale ? 1 : 0)) ?? []; + assert.deepEqual([...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand), ...auction].map(c => c.id).sort(), treacheryDeck(['ecaz']).map(c => c.id).sort()); + assert.equal(g.richeseCache?.length ?? 0, 0); + if (g.leaderSkills) { + validateLeaderSkills(g.leaderSkills, g.players); + assert.deepEqual([...g.leaderSkills.deck, ...Object.values(g.leaderSkills.offers).flatMap(o => o.cards), + ...g.leaderSkills.assignments.map(a => a.skill)].sort(), LEADER_SKILL_CARDS.map(c => c.id).sort()); + assert.ok(g.leaderSkills.assignments.every(a => a.leader !== DUKE_VIDAL_ID)); + } + if (g.homeworlds) homeworldGameIntegrity(g); + if (g.discoveries) validateDiscoveryState(g.discoveries); + const homes = g.homeworlds?.custody ? homeworldForceGroups(homeworldContext(g), g.homeworlds.custody) : []; + for (const p of g.players) { + const visitors = homes.filter(h => h.native !== p.id).map(h => h.forces[p.id] ?? { normal: 0, elite: 0 }); + assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((a, n) => a + n, 0) + visitors.reduce((a, f) => a + f.normal + f.elite, 0), 20); + assert.deepEqual(p.leaders.map(l => l.id).sort(), leaders(p.faction).map(l => l.id).sort()); + if (p.elites) assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((a, n) => a + n, 0) + visitors.reduce((a, f) => a + f.elite, 0), p.faction === 'fremen' ? 3 : 5); + } + const retired = g.moritaniAssassinate?.opportunities.filter(r => r.stage === 'replaced').map(r => r.card!) ?? []; + assert.deepEqual([...g.traitorReserve!, ...g.players.flatMap(p => [...p.traitors, ...p.traitorChoices]), ...retired, + ...(g.ecazLoyalty?.card ? [g.ecazLoyalty.card] : [])].sort(), traitorDeck(g.players).sort()); + assert.equal(pairedE3NexusPlayer(g, g.players.find(p => p.faction === 'ecaz')!.id).leaders.length, 5); + assert.equal(g.dukeVidal!.leader.id, DUKE_VIDAL_ID); validateAmbassadors(g.ecazAmbassadors!); + assert.deepEqual(g.moritaniTerror!.tokens.map(t => t.kind).sort(), [...TERROR_KINDS].sort()); +} diff --git a/tests/homeworld-standalone-nexus-runtime.test.ts b/tests/homeworld-standalone-nexus-runtime.test.ts index 68c45a11..e7aa69f4 100644 --- a/tests/homeworld-standalone-nexus-runtime.test.ts +++ b/tests/homeworld-standalone-nexus-runtime.test.ts @@ -364,7 +364,7 @@ for (const faction of ['ecaz', 'moritani'] as const) void test(`${faction} origi custody(f.entry.after); }); -void test('original supplied offers keep human choices while Duke training, Advanced Harkonnen and paired E3 reject before custody conversion', () => { +void test('original skill offers keep human choices while Duke training and Advanced Harkonnen reject before custody conversion', () => { const original = createGame('SUPPLIEDHWE3NX', newPlayer('native-original', 'Original native', 'ecaz'), false, ['ecaz']); joinGame(original, newPlayer('guild-original', 'Original Guild', 'guild')); const f = setup({ initial: original }); @@ -382,16 +382,9 @@ void test('original supplied offers keep human choices while Duke training, Adva assert.ok(player(after, f.owner).leaders.some(l => l.id === assignment.leader)); assert.notEqual(assignment.leader, DUKE_VIDAL_ID); }); - const resumed = setup({ initial: reload(f.offered) }); - const actual = resumed.afterSetup.leaderSkills!.assignments.find(a => a.owner === f.owner)!; - assert.ok(own.offer!.cards.includes(actual.skill)); - assert.equal(player(resumed.afterSetup, f.owner).reserves, 14); - assert.equal(pool(resumed.afterSetup, f.owner, 'homeworld:ecaz').normal, 14); custody(resumed.afterSetup); - assert.throws(() => setup({ initial: f.afterSetup }), /lobby|ORIGINAL|setup/); for (const [advanced, factions] of [ [true, ['ecaz', 'guild', 'harkonnen']], [true, ['moritani', 'guild', 'harkonnen']], - [true, ['ecaz', 'guild', 'moritani']], ] as const) { let g = createGame('ORIGINALSTANDALONEGUARD', newPlayer(factions[0], factions[0], factions[0]), advanced, ['ecaz']); for (const faction of factions.slice(1)) joinGame(g, newPlayer(faction, faction, faction)); diff --git a/tests/mixed-nexus-modules-runtime.test.ts b/tests/mixed-nexus-modules-runtime.test.ts new file mode 100644 index 00000000..35d91b81 --- /dev/null +++ b/tests/mixed-nexus-modules-runtime.test.ts @@ -0,0 +1,405 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, viewGame } from '../game/engine'; +import type { Action, Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { CHOAM_AUDITOR_ID } from '../game/choam-auditor'; +import { ownedTech } from '../game/tech-tokens'; +import { quoteSpiceCollection } from '../game/board-resolution-quote'; +import { combatArmy } from '../game/combat-location'; +import { nexusFaceDancersAction } from '../game/nexus-tleilaxu-options'; +import { choamPowerAction, choamPowerPlays } from '../game/choam-power-options'; +import { + advanceMixedNexus as advance, createMixedNexusProgramme as programme, createMixedNexusSetup as setup, + mixedNexusClean as clean, mixedNexusInventory as inventory, mixedNexusLeader as leader, + mixedNexusOpenBattle as open, mixedNexusPlayer as player, mixedNexusQuote as quote, + mixedNexusReload as reload, mixedNexusRevealPlans as plans, mixedNexusShip as ship, + nextMixedNexusStep as next, settleMixedNexus as settle, stepMixedNexus as step, +} from './fixture-mixed-nexus-modules'; +import type { MixedNexusProgramme } from './fixture-mixed-nexus-modules'; + +function reject(g: Game, actor: string, action: Action): void { + const before = reload(g); + assert.throws(() => applyAction(g, actor, action)); + assert.deepEqual(g, before, 'Rejected consumers cannot debit wallets, move counters or consume original stock.'); +} +function policies(g: Game, actor: string, select: (action: Action) => boolean, + outcome: (after: Game, action: Action) => void): void { + for (const difficulty of DIFFICULTIES) { + const saved = reload(g); player(saved, actor).bot = difficulty; + const action = botActions(viewGame(reload(saved), actor)).find(select); + assert.ok(action, `${difficulty} consumes the actual owning window after JSON continuation.`); + const after = step(reload(saved), { actor, action }); outcome(after, action); + if (after.status === 'playing') inventory(after); + } +} +function movement(g: Game, actor: string): Game { + return advance(g, q => q.turn === 2 && q.phase === 5 && q.active === actor && clean(q)); +} +/** These are explicit test-author choices. The reusable human controller stops + * at each of these windows rather than choosing another player's physical stock. */ +function finish(g: Game): Game { + for (let n = 0; n < 600; n++) { + if (!g.battle && clean(g)) return g; + let control = next(g); + if (!control && !g.response && !g.phaseOpening) { + const d = g.decision; assert.ok(d); + if (d.kind === 'battleLosses') { + const elite = Math.max(...d.options.map(o => o.elite)); + control = { actor: d.player, action: { type: 'decision', choice: d.options.findIndex(o => o.elite === elite) } }; + } else if (d.kind === 'battleCards') control = { actor: d.player, action: { type: 'decision', discard: [] } }; + else if (d.kind === 'techToken') control = { actor: d.player, action: { type: 'decision', token: d.choices[0] } }; + else if (d.kind === 'faceDance') control = { actor: d.player, action: { type: 'decision', reveal: false } }; + else throw Error(`The test must explicitly choose ${d.kind}.`); + } + assert.ok(control); g = step(g, control); + } + throw Error('Original mixed battle did not finish.'); +} +function boundary(g: Game, kind: 'sukRescue' | 'ixSubstitution' | 'battleCards' | 'techToken' | 'faceDance'): Game { + for (let n = 0; n < 600; n++) { + if (!g.response && !g.phaseOpening && g.decision?.kind === kind) return g; + let control = next(g); + if (!control && !g.response && !g.phaseOpening && g.decision?.kind === 'battleLosses') { + const d = g.decision, elite = Math.max(...d.options.map(o => o.elite)); + control = { actor: d.player, action: { type: 'decision', choice: d.options.findIndex(o => o.elite === elite) } }; + } + assert.ok(control, `Choose ${g.decision?.kind ?? 'sealed plan'} before ${kind}.`); g = step(g, control); + } + throw Error(`Original aftermath did not reach ${kind}.`); +} +function collectionIncome(g: Game, actor: string): number { + if (g.phase !== 7 || !clean(g)) return 0; + return quoteSpiceCollection(g).receipts.filter(r => r.player === actor) + .reduce((n, r) => n + r.collected + r.strongholds, 0); +} +function originalClosing(f: MixedNexusProgramme): void { + assert.ok(f.firstMentat && f.alliance && f.drawing); + assert.equal(f.firstMentat.before.turn, 1); assert.equal(f.firstMentat.after.turn, 2); + const allies = f.alliance.players.filter(p => p.ally); + assert.equal(allies.length, 2); + assert.equal(allies[0].ally, allies[1].id); assert.equal(allies[1].ally, allies[0].id); + assert.equal(f.drawing.phase, 1); + if (f.drawing.advanced) assert.ok(f.drawing.spiceDiscard.every(pile => pile.length > 0), 'Both original Spice piles precede closing.'); + for (const draw of f.draws) { + const actor = draw.step.actor, face = player(draw.after, actor).faction; + assert.equal(draw.after.nexusCards!.cards!.hands[actor], face); + reject(draw.after, actor, draw.step.action); + policies(draw.before, actor, a => a.type === 'nexusCardChoice', after => { + assert.equal(after.nexusCards!.cards!.hands[actor], face); + }); + } + inventory(f.game); +} + +void test('mixed original Ix offer/HMS and all14 Auditor exclusion retain physical native setup', () => { + const f = setup({ advanced: true, skills: true, homeworlds: true, tech: true, strongholds: true }); + const ix = f.seats.ixians!, choam = f.seats.choam!; + assert.ok(f.ixOffer && f.offered); + assert.equal(player(f.afterSetup, ix).forces['hidden_mobile_stronghold:0'], 6); + assert.equal(player(f.afterSetup, ix).elites!.forces['hidden_mobile_stronghold:0'], 3); + const offer = viewGame(f.offered, choam).leaderSkills!; + assert.ok(player(f.offered, choam).leaders.some(l => l.id === CHOAM_AUDITOR_ID)); + reject(f.offered, choam, { type: 'leaderSkill', event: offer.offer!.event, + skill: offer.offer!.cards[0], leader: CHOAM_AUDITOR_ID }); + reject(f.offered, choam, { type: 'leaderSkill', event: offer.offer!.event, + skill: offer.offer!.cards[0], leader: player(f.offered, ix).leaders[0].id }); + inventory(f.afterSetup); +}); + +void test('Basic two-family two-seat setup and smaller four-seat real closing require no Advanced module', () => { + const two = programme({ advanced: false, skills: false, roster: ['ixians', 'richese'] }); + assert.equal(player(two.game, two.seats.ixians!).forces['hidden_mobile_stronghold:0'], 6); + assert.equal(player(two.game, two.seats.richese!).noField!.tokens.length, 3); + assert.equal(two.game.richeseCache!.length, 10); inventory(two.game); + const four = programme({ advanced: false, skills: false, tech: true, + roster: ['ixians', 'richese', 'guild', 'fremen'] }); + originalClosing(four); + assert.ok(four.firstMarker); + assert.equal(player(four.firstMarker.after, four.seats.richese!).spice, + player(four.firstMarker.before, four.seats.richese!).spice - 1); + assert.equal(player(four.firstMarker.after, four.seats.richese!).reserves, + player(four.firstMarker.before, four.seats.richese!).reserves); + const three = programme({ advanced: false, skills: false, tech: true, + roster: ['tleilaxu', 'choam', 'guild'] }); + originalClosing(three); + assert.equal(three.game.nexusCards!.cards!.hands[three.seats.tleilaxu!], 'tleilaxu'); + assert.equal(player(three.game, three.seats.tleilaxu!).faceDancers!.length, 3); +}); + +void test('bare mixed Nexus all14 Smuggler pays from the real printed board pile, not a silently absent shared mode', () => { + const f = programme({ advanced: true, skills: true, skill: 'smuggler', + roster: ['ixians', 'richese', 'guild', 'fremen'] }); + const ix = f.seats.ixians!, guild = f.seats.guild!, trainer = f.trainers[ix]; + const pile = Object.entries(f.game.spice).find(([key, amount]) => key.startsWith('rock_outcroppings:') && amount > 0); assert.ok(pile); + const needed = new Set([ix, guild]); let g = reload(f.game); + while (needed.size) { + g = advance(g, q => q.phase === 5 && clean(q) && needed.has(q.active!)); + const actor = g.active!; g = ship(g, actor, 'rock_outcroppings', 2).after; needed.delete(actor); + if (needed.size) g = step(g, { actor, action: { type: 'endMovement' } }); + } + g = open(g, ix, guild, 'rock_outcroppings'); const purse = player(g, ix).spice; + g = plans(g, [{ actor: ix, action: { type: 'battlePlan', leader: trainer, dial: 0, support: 0 } }, + { actor: guild, action: { type: 'battlePlan', leader: leader(g, guild), dial: 0, support: 0 } }]); + assert.equal(quote(g).winner, ix); g = finish(g); + const receipt = g.lastBattleContext!.smugglerCollection; assert.ok(receipt); + const strength = player(g, ix).leaders.find(l => l.id === trainer)!.strength; + assert.equal(receipt.amount, Math.min(strength, pile[1])); + assert.equal(g.spice[pile[0]], 0); + assert.equal(player(g, ix).spice, purse + pile[1], 'Smuggler and ordinary Collection cannot pay the same spice twice.'); + inventory(g); +}); + +for (const location of ['homeworld:ixians', 'homeworld:guild', 'shrine'] as const) void test( + `mixed Ix ${location}: real typed Suk precedes same-quote residual Cyborg exchange without borrowed returns`, () => { + const nested = location === 'shrine'; + const f = programme({ advanced: true, skills: true, homeworlds: true, discovery: nested, tech: true, strongholds: true }); + originalClosing(f); + const ix = f.seats.ixians!, guild = f.seats.guild!, trainer = f.trainers[ix]; + if (nested) { + assert.ok(f.firstArrival && f.entry && f.vote && f.ride); + assert.deepEqual(combatArmy(f.entry.after, ix, 'shrine'), { normal: 1, elite: 1 }); + assert.equal(player(f.entry.after, ix).spice, player(f.entry.before, ix).spice); + assert.equal(player(f.entry.after, ix).reserves, player(f.entry.before, ix).reserves); + assert.equal(player(f.entry.after, ix).elites!.reserves, player(f.entry.before, ix).elites!.reserves); + reject(f.entry.before, guild, f.entry.step.action); + assert.equal(player(f.ride.after, f.seats.fremen!).reserves, player(f.ride.before, f.seats.fremen!).reserves - 2); + assert.equal(player(f.ride.after, f.seats.fremen!).elites!.reserves, player(f.ride.before, f.seats.fremen!).elites!.reserves - 1); + } + let g = reload(f.game); + if (location === 'homeworld:ixians') g = ship(movement(g, guild), guild, location, 3).after; + else if (location === 'homeworld:guild') g = ship(movement(g, ix), ix, location, 4, 3).after; + else { + const needed = new Set([ix, guild]); + while (needed.size) { + g = advance(g, q => q.phase === 5 && clean(q) && needed.has(q.active!)); const actor = g.active!; + g = ship(g, actor, location, 3, actor === ix ? 2 : 0).after; needed.delete(actor); + if (needed.size) g = step(g, { actor, action: { type: 'endMovement' } }); + } + } + g = open(g, ix, guild, location); + const offer = viewGame(g, ix).nexusSuboids!.offer!; assert.ok(!offer.blocked); + const cunning: Action = { type: 'nexusSuboids', event: offer.event }; + reject(g, guild, cunning); reject(g, ix, { ...cunning, event: 'old-battle' }); + policies(g, ix, a => a.type === 'nexusSuboids', after => { + const resolved = settle(after); + assert.deepEqual(combatArmy(resolved, ix, location), combatArmy(g, ix, location)); + assert.equal(player(resolved, ix).spice, player(g, ix).spice); + assert.equal(resolved.nexusCards!.cards!.hands[ix], null); + assert.equal(viewGame(resolved, ix).battle!.ownForces!.normalFreeSupport, true); + }); + g = settle(step(g, { actor: ix, action: cunning })); reject(g, ix, cunning); + const held = player(g, ix).hand.find(c => c.kind === 'worthless')!, before = reload(g); + const homeBefore = combatArmy(g, ix, 'homeworld:ixians'), tokens = ownedTech(g.techTokens, guild); + g = plans(g, [{ actor: ix, action: { type: 'battlePlan', leader: trainer, dial: 3, support: 0, weapon: held.id } }, + { actor: guild, action: { type: 'battlePlan', leader: leader(g, guild), dial: location === 'homeworld:guild' ? 1 : 0, + support: location === 'homeworld:guild' ? 1 : 0 } }]); + const receipt = quote(g); assert.equal(receipt.winner, ix); + assert.equal(receipt.payments.find(p => p.player === ix)!.ownPayment, 0); + assert.equal(player(g, ix).spice, player(before, ix).spice, 'Plans never prepay support.'); + g = boundary(g, 'sukRescue'); const d = g.decision; assert.ok(d?.kind === 'sukRescue'); + const rescueChoice = d.options.findIndex(o => o.normal === 0 && o.elite === 1 && o.kept?.kind === 'elite'); assert.ok(rescueChoice >= 0); + const rescue: Action = { type: 'decision', event: d.event, choice: rescueChoice }; + reject(g, guild, rescue); reject(g, ix, { ...rescue, event: 'old-rescue' }); + if (location !== 'homeworld:ixians') reject(g, ix, { ...rescue, + destinations: { 'homeworld:ixians': { normal: 0, elite: 1 } } }); + g = step(g, { actor: ix, action: rescue }); g = boundary(g, 'ixSubstitution'); + const source = location === 'shrine' ? 'shrine:0' : location; + assert.deepEqual(g.pendingIxSubstitution!.losses, { [source]: 2 }); + assert.equal(player(g, ix).elites!.tanks, player(before, ix).elites!.tanks + 2); + assert.equal(player(g, ix).tanks, player(before, ix).tanks + 2); + for (const token of tokens) assert.equal(g.techTokens![token].owner, guild); + const residual = reload(g), pool = combatArmy(g, ix, location); + const exchange: Action = { type: 'decision', sources: { [source]: 1 }, recover: { [source]: 1 } }; + reject(g, guild, exchange); reject(g, ix, { ...exchange, recover: { [source]: 3 } }); + reject(g, ix, { ...exchange, sources: { reserves: 1 } }); + if (location !== 'homeworld:ixians') reject(g, ix, { ...exchange, sources: { 'homeworld:ixians': 1 } }); + policies(g, ix, a => a.type === 'decision' && (a.sources !== undefined || a.decline === true), (after, action) => { + const count = action.decline ? 0 : Object.values(action.recover as Record).reduce((n, f) => n + f, 0); + assert.equal(player(after, ix).elites!.tanks, player(residual, ix).elites!.tanks - count); + assert.deepEqual(combatArmy(after, ix, location), { normal: pool.normal - count, elite: pool.elite + count }); + assert.equal(player(after, ix).tanks, player(residual, ix).tanks); + assert.equal(player(after, ix).spice, player(residual, ix).spice); + }); + g = step(g, { actor: ix, action: exchange }); + assert.deepEqual(combatArmy(g, ix, location), { normal: pool.normal - 1, elite: pool.elite + 1 }); + assert.equal(player(g, ix).elites!.tanks, player(residual, ix).elites!.tanks - 1); + assert.equal(player(g, ix).tanks, player(residual, ix).tanks); + if (location !== 'homeworld:ixians') assert.deepEqual(combatArmy(g, ix, 'homeworld:ixians'), homeBefore); + g = boundary(g, 'battleCards'); + reject(g, guild, { type: 'decision', discard: [held.id] }); + g = step(g, { actor: ix, action: { type: 'decision', discard: [held.id] } }); + g = finish(g); + assert.equal(g.discard.filter(c => c.id === held.id).length, 1); + assert.deepEqual(combatArmy(g, guild, location), { normal: 0, elite: 0 }); + const defeated = combatArmy(before, guild, location); + assert.equal(player(g, guild).tanks, player(before, guild).tanks + defeated.normal + defeated.elite); + for (const token of tokens) assert.equal(g.techTokens![token].owner, ix); + inventory(g); + }); + +void test('mixed nested original winning-card cleanup/Tech precede matching native Face Dance, trainer retirement and source-local refresh', () => { + const f = programme({ advanced: true, skills: true, homeworlds: true, discovery: true, tech: true, strongholds: true, skill: 'smuggler' }); + const ix = f.seats.ixians!, tl = f.seats.tleilaxu!, guild = f.seats.guild!, trainer = f.trainers[ix]; + assert.ok(player(f.afterSetup, tl).faceDancers!.some(c => c.leader === trainer && !c.revealed)); + let g = ship(movement(f.game, tl), tl, 'shrine', 2).after; + g = open(g, ix, tl, 'shrine'); + const held = player(g, ix).hand.find(c => c.kind === 'worthless')!, ownTraining = g.leaderSkills!.assignments.find(a => a.owner === tl)!; + const before = reload(g), tokens = ownedTech(g.techTokens, tl); + assert.equal(tokens.length, 1); + g = plans(g, [{ actor: ix, action: { type: 'battlePlan', leader: trainer, dial: 0, support: 0, weapon: held.id } }, + { actor: tl, action: { type: 'battlePlan', leader: leader(g, tl), dial: 0, support: 0 } }]); + assert.equal(quote(g).winner, ix); + g = boundary(g, 'battleCards'); + for (const token of tokens) assert.equal(g.techTokens![token].owner, tl); + reject(g, tl, { type: 'decision', reveal: true, sources: { reserves: 1 }, sector: 0 }); + g = step(g, { actor: ix, action: { type: 'decision', discard: [held.id] } }); + // A sole token transfers automatically; multiple-token menus remain human-owned. + g = boundary(g, 'faceDance'); + for (const token of tokens) assert.equal(g.techTokens![token].owner, ix); + assert.equal(g.discard.filter(c => c.id === held.id).length, 1); + const survivor = combatArmy(g, ix, 'shrine'), stock = reload(g), native = combatArmy(g, tl, 'homeworld:tleilaxu'); + assert.ok(survivor.normal + survivor.elite >= 1); + const dance: Action = { type: 'decision', reveal: true, sources: { reserves: 1 }, sector: 0 }; + reject(g, guild, dance); reject(g, tl, { ...dance, sources: { reserves: survivor.normal + survivor.elite + 1 } }); + reject(g, tl, { ...dance, sources: { 'homeworld:ixians': 1 } }); + policies(g, tl, a => a.type === 'decision' && a.reveal !== undefined, (after, action) => { + const count = action.reveal ? Object.values((action.sources ?? {}) as Record).reduce((n, f) => n + f, 0) : 0; + assert.equal(player(after, ix).leaders.find(l => l.id === trainer)!.dead, !!action.reveal); + assert.equal(player(after, tl).faceDancers!.find(c => c.leader === trainer)!.revealed, !!action.reveal); + assert.deepEqual(combatArmy(after, ix, 'shrine'), action.reveal ? { normal: 0, elite: 0 } : survivor); + assert.equal(combatArmy(after, tl, 'shrine').normal, count); + for (const token of tokens) assert.equal(after.techTokens![token].owner, ix); + assert.equal(player(after, tl).spice, player(stock, tl).spice); + }); + g = step(g, { actor: tl, action: dance }); + assert.equal(player(g, ix).reserves, player(stock, ix).reserves + survivor.normal + survivor.elite); + assert.equal(player(g, ix).elites!.reserves, player(stock, ix).elites!.reserves + survivor.elite); + assert.deepEqual(combatArmy(g, tl, 'homeworld:tleilaxu'), { normal: native.normal - 1, elite: 0 }); + assert.deepEqual(combatArmy(g, tl, 'shrine'), { normal: 1, elite: 0 }); + assert.equal(g.leaderSkills!.assignments.some(a => a.leader === trainer), false); + assert.equal(g.leaderSkills!.deck.filter(s => s === 'smuggler').length, 1); + assert.equal(g.leaderSkills!.assignments.find(a => a.owner === tl)!.leader, ownTraining.leader); + assert.equal(player(g, ix).tanks, player(before, ix).tanks, 'Face Dance returns surviving physical counters; it is not a battle casualty.'); + reject(g, tl, dance); + const offer = viewGame(g, tl).nexusTleilaxu!.cunning!; assert.ok(!offer.blocked); + const refresh = nexusFaceDancersAction(viewGame(g, tl), offer.event); assert.ok(refresh); + const reserve = [...g.traitorReserve!], untouched = player(g, tl).faceDancers!.filter(c => !c.revealed); + const allowance = player(g, tl).faceDancerReplacedTurn; + reject(g, guild, refresh); reject(g, tl, { ...refresh, leaders: [trainer] }); + policies(g, tl, a => a.type === 'nexusFaceDancers', after => { + assert.deepEqual(player(after, tl).faceDancers!.filter(c => untouched.some(old => old.leader === c.leader)), untouched); + assert.deepEqual(player(after, tl).faceDancers!.filter(c => !untouched.some(old => old.leader === c.leader)), [{ leader: reserve[0], revealed: false }]); + assert.deepEqual([...after.traitorReserve!].sort(), [...reserve.slice(1), trainer].sort()); + assert.equal(player(after, tl).faceDancerReplacedTurn, allowance); + assert.equal(after.nexusCards!.cards!.hands[tl], null); + for (const token of tokens) assert.equal(after.techTokens![token].owner, ix); + assert.deepEqual(combatArmy(after, tl, 'shrine'), { normal: 1, elite: 0 }); + }); + g = step(g, { actor: tl, action: refresh }); reject(g, tl, refresh); g = finish(g); inventory(g); +}); + +void test('mixed native Richese signed five-plus-three invoice pays Guild once, materializes original supply and rejects borrowed companions', () => { + const f = programme({ advanced: true, skills: false, homeworlds: true, tech: true, + roster: ['ixians', 'choam', 'richese', 'guild', 'fremen'] }); + originalClosing(f); const rich = f.seats.richese!, guild = f.seats.guild!, choam = f.seats.choam!; + let g = reload(f.game); + const arrivals = new Set([rich, guild]); + while (arrivals.size) { + g = advance(g, s => s.turn === 2 && s.phase === 5 && clean(s) && arrivals.has(s.active!)); + const actor = g.active!; + if (actor === guild) g = ship(g, guild, 'arrakeen', 3).after; + else { + let before = reload(g); + const p = player(g, rich), offer = viewGame(g, rich).nexusRicheseCunning!; assert.ok(!offer.blocked); + const request: Action = { type: 'ship', territory: 'arrakeen', sector: 10, + noField: p.noField!.tokens.find(t => t.value === 3)!.id, + revealedToken: p.noField!.tokens.find(t => t.value === 5)!.id, + event: p.noFieldEvent, nexus: offer.event, allyPayment: 0 }; + const native = combatArmy(g, rich, 'homeworld:richese'); assert.ok(native.normal >= 8); + reject(g, guild, request); reject(g, rich, { ...request, nexus: 'old-richese' }); + reject(g, rich, { ...request, revealedToken: request.noField }); + reject(g, rich, { ...request, smugglerCompanion: true }); + reject(g, rich, { ...request, source: 'homeworld:guild' }); + g = settle(step(g, { actor: rich, action: request })); + assert.equal(player(g, rich).spice, player(before, rich).spice - 1); + assert.equal(player(g, guild).spice, player(before, guild).spice + 1); + assert.equal(player(g, choam).spice, player(before, choam).spice, 'Shipment is not CHOAM battle support income.'); + assert.deepEqual(combatArmy(g, rich, 'homeworld:richese'), { normal: native.normal - 5, elite: 0 }); + assert.equal(player(g, rich).forces['arrakeen:10'], 5); + assert.equal(g.nexusCards!.cards!.hands[rich], null); reject(g, rich, request); + before = reload(g); + const reveal: Action = { type: 'revealNoField', token: request.noField, event: player(g, rich).noFieldEvent }; + reject(g, rich, { ...reveal, event: 'old-marker' }); + g = step(g, { actor: rich, action: reveal }); + assert.equal(player(g, rich).forces['arrakeen:10'], 8); + assert.deepEqual(combatArmy(g, rich, 'homeworld:richese'), { normal: native.normal - 8, elite: 0 }); + for (const payer of [rich, guild, choam]) assert.equal(player(g, payer).spice, player(before, payer).spice, 'Hidden three materialization cannot repeat the signed shipment payment.'); + } + arrivals.delete(actor); + if (arrivals.size) g = step(g, { actor, action: { type: 'endMovement' } }); + } + // Both actual actors use their existing movement slots; CHOAM receives half + // subsequent paid support, not the already settled marker invoice. + g = open(g, rich, guild, 'arrakeen'); + const before = reload(g); + g = plans(g, [{ actor: rich, action: { type: 'battlePlan', leader: leader(g, rich, true), dial: 2, support: 2 } }, + { actor: guild, action: { type: 'battlePlan', leader: leader(g, guild), dial: 2, support: 2 } }]); + const receipt = quote(g); assert.equal(receipt.winner, rich); + assert.equal(player(g, choam).spice, player(before, choam).spice, 'Income waits for actual battle resolution.'); + g = finish(g); + assert.equal(player(g, choam).spice, player(before, choam).spice + 2 + collectionIncome(g, choam)); + assert.equal(player(g, rich).spice, player(before, rich).spice - 2 + collectionIncome(g, rich)); + assert.equal(player(g, guild).spice, player(before, guild).spice - 2 + collectionIncome(g, guild)); + assert.equal(player(g, rich).forces['arrakeen:10'], 6); + assert.equal(player(g, guild).tanks, player(before, guild).tanks + 3); inventory(g); +}); + +for (const fuelKind of ['special', 'shield'] as const) void test( + `mixed CHOAM original ${fuelKind} fuels any-Treachery Cunning and delays actual other-payer support through Suk`, () => { + const f = programme({ advanced: true, skills: true, homeworlds: true, tech: true, strongholds: true, + skillOwner: 'choam', skill: 'suk-graduate', fuelKind }); + const choam = f.seats.choam!, guild = f.seats.guild!, trainer = f.trainers[choam]; + let g = movement(f.game, choam); + const fuel = player(g, choam).hand.find(c => c.kind === fuelKind && (fuelKind !== 'special' || c.effect !== 'karama')); assert.ok(fuel); + const play = choamPowerPlays(viewGame(g, choam), 'kulon').find(p => p.source === 'nexus' && p.card.id === fuel.id); assert.ok(play); + const request = choamPowerAction(viewGame(g, choam), play); assert.ok(request); + const beforeFuel = reload(g); + reject(g, guild, request); reject(g, choam, { ...request, nexus: 'old-choam' }); + reject(g, choam, { ...request, card: player(g, guild).hand[0].id }); + g = settle(step(g, { actor: choam, action: request })); + assert.equal(player(g, choam).spice, player(beforeFuel, choam).spice); + assert.equal(g.discard.filter(c => c.id === fuel.id).length, 1); + assert.equal(player(g, choam).hand.some(c => c.id === fuel.id), false); + assert.equal(g.choamMovement!.bonus, 1); assert.equal(g.nexusCards!.cards!.hands[choam], null); + reject(g, choam, request); + g = ship(g, choam, 'homeworld:guild', 3).after; + g = open(g, choam, guild, 'homeworld:guild'); const before = reload(g), tokens = ownedTech(g.techTokens, guild); + g = plans(g, [{ actor: choam, action: { type: 'battlePlan', leader: trainer, dial: 2, support: 2 } }, + { actor: guild, action: { type: 'battlePlan', leader: leader(g, guild), dial: 2, support: 2 } }]); + assert.equal(player(g, choam).spice, player(before, choam).spice); + g = boundary(g, 'sukRescue'); const d = g.decision; assert.ok(d?.kind === 'sukRescue'); + assert.equal(player(g, choam).spice, player(before, choam).spice - 2); + assert.equal(player(g, guild).spice, player(before, guild).spice - 2); + assert.equal(g.pendingChoamBattleIncome!.amount, 1, 'Own support is excluded; only the actual Guild payer funds native income.'); + for (const token of tokens) assert.equal(g.techTokens![token].owner, guild); + const source = combatArmy(g, choam, 'homeworld:guild'), home = combatArmy(g, choam, 'homeworld:choam'); + const choice = d.options.findIndex(o => o.normal === 1 && o.elite === 0 && o.kept?.kind === 'normal'); assert.ok(choice >= 0); + const rescue: Action = { type: 'decision', event: d.event, choice }; + reject(g, guild, rescue); reject(g, choam, { ...rescue, event: 'old-rescue' }); + policies(g, choam, a => a.type === 'decision' && a.choice !== undefined, (after, action) => { + const saved = d.options[Number(action.choice)], kept = saved.kept?.kind === 'normal' ? 1 : 0; + assert.equal(player(after, choam).tanks, player(g, choam).tanks + 2 - saved.normal - saved.elite); + assert.deepEqual(combatArmy(after, choam, 'homeworld:guild'), { normal: source.normal - 2 + kept, elite: 0 }); + assert.deepEqual(combatArmy(after, choam, 'homeworld:choam'), { normal: home.normal + saved.normal - kept, elite: 0 }); + assert.equal(player(after, choam).spice, player(g, choam).spice + (after.pendingChoamBattleIncome ? 0 : 1) + collectionIncome(after, choam)); + }); + g = step(g, { actor: choam, action: rescue }); g = finish(g); + assert.equal(g.lastBattleContext!.winner, choam); + assert.deepEqual(combatArmy(g, choam, 'homeworld:guild'), { normal: 2, elite: 0 }); + assert.equal(player(g, choam).tanks, player(before, choam).tanks + 1); + assert.equal(player(g, choam).spice, player(before, choam).spice - 2 + 1 + collectionIncome(g, choam)); + for (const token of tokens) assert.equal(g.techTokens![token].owner, choam); + inventory(g); + }); diff --git a/tests/moritani-assassinate-quote.test.ts b/tests/moritani-assassinate-quote.test.ts index 4699b0b5..35635059 100644 --- a/tests/moritani-assassinate-quote.test.ts +++ b/tests/moritani-assassinate-quote.test.ts @@ -90,7 +90,6 @@ void test('public trigger requires a native Advanced Moritani normal loss, a sur { opposingLeader: 'cheap-hero-traitor' }, { opposingLeader: 'emperor-0' }, { faction: 'harkonnen', opposingLeader: 'harkonnen-0' }, - { faction: 'ecaz', opposingLeader: 'ecaz-0' }, { turn: 0 }, { turn: 1.5 }, { event: '' }, diff --git a/tests/paired-e3-nexus-modules-runtime.test.ts b/tests/paired-e3-nexus-modules-runtime.test.ts new file mode 100644 index 00000000..8ce3852f --- /dev/null +++ b/tests/paired-e3-nexus-modules-runtime.test.ts @@ -0,0 +1,437 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, createGame, initializeLeaderSkillsGameForAudit, initializePairedNexusGameForAudit, joinGame, newPlayer, viewGame } from '../game/engine'; +import type { Action, Game } from '../game/engine'; +import type { FactionId } from '../game/catalog'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { leaders } from '../game/cards'; +import { DUKE_VIDAL_ID } from '../game/duke-vidal'; +import { ownedTech } from '../game/tech-tokens'; +import { quoteSpiceCollection } from '../game/board-resolution-quote'; +import { territory } from '../game/board'; +import { nexusEcazDukeAction } from '../game/nexus-ecaz-duke-options'; +import { homeworldForceGroups } from '../game/homeworld-custody'; +import { homeworldContext } from '../game/homeworld-game'; +import { + advancePairedE3Nexus as advance, createPairedE3NexusProgramme as programme, + createPairedE3NexusSetup as setup, nextPairedE3NexusStep, + openPairedE3NexusBattle as openBattle, pairedE3NexusClean as clean, + pairedE3NexusInventory as inventory, pairedE3NexusPlayer as player, + pairedE3NexusPolicy as policy, pairedE3NexusReload as reload, + quietPairedE3NexusChoice as quiet, quotePairedE3NexusBattle as quote, + revealPairedE3Nexus as reveal, shipPairedE3Nexus as ship, stepPairedE3Nexus as step, +} from './fixture-paired-e3-nexus-modules'; +import type { PairedE3NexusOptions, PairedE3NexusStep } from './fixture-paired-e3-nexus-modules'; + +function reject(g: Game, actor: string, action: Action, error?: RegExp): void { + const before = structuredClone(g); + if (error) assert.throws(() => applyAction(g, actor, action), error); + else assert.throws(() => applyAction(g, actor, action)); + assert.deepEqual(g, before, 'A rejected consumer cannot spend, kill, place or transfer original pieces'); +} +function policies(g: Game, actor: string, effect: (after: Game, action: Action) => void): void { + for (const difficulty of DIFFICULTIES) { + const action = policy(reload(g), actor, difficulty)[0]; assert.ok(action, difficulty); + const after = step(reload(g), { actor, action }); effect(after, action); inventory(after); + } +} +function settlementChoice(g: Game): PairedE3NexusStep | null { + const d = g.decision; if (!d || g.response || g.phaseOpening) return null; + if (d.kind === 'battleLosses') return { actor: d.player, action: { type: 'decision', choice: 0 } }; + if (d.kind === 'battleCards') return { actor: d.player, action: { type: 'decision', discard: [] } }; + if (d.kind === 'techToken') return { actor: d.player, action: { type: 'decision', token: d.choices[0] } }; + if (d.kind === 'moritaniRetention') return { actor: d.player, action: { type: 'decision', keep: null, + ...(d.source === 'nexus' ? { event: d.event } : {}) } }; + if (d.kind === 'sukRescue') { + const choice = d.options.map((o, index) => ({ index, saved: o.normal + o.elite, kept: !!o.kept })) + .sort((a, b) => b.saved - a.saved || Number(b.kept) - Number(a.kept))[0].index; + return { actor: d.player, action: { type: 'decision', event: d.event, choice } }; + } + return quiet(g); +} +function settle(g: Game, stopAssassination = false, normalCaller?: string): Game { + for (let n = 0; n < 400; n++) { + if (!g.response && !g.phaseOpening && (stopAssassination && g.decision?.kind === 'moritaniAssassinate' || + !g.battle && clean(g))) return g; + const next = nextPairedE3NexusStep(g) ?? settlementChoice(g); assert.ok(next); + if (next.action.type === 'traitorCall' && next.actor === normalCaller) next.action.call = true; + g = step(g, next); + } + throw Error('Original battle settlement did not finish'); +} +function collectionIncome(g: Game, actor: string): number { + return g.phase === 7 ? quoteSpiceCollection(g).receipts.find(r => r.player === actor)?.strongholds ?? 0 : 0; +} +function shipWhenActive(g: Game, actor: string, location: string, amount: number): Game { + g = advance(g, s => s.phase === 5 && s.active === actor && clean(s), undefined, quiet); + return ship(g, actor, location, amount).after; +} +function guildLoss(options: PairedE3NexusOptions = {}, normalCall = false) { + const f = programme(options), guild = f.game.players.find(p => p.faction === 'guild')!.id; + let g = shipWhenActive(f.game, f.moritani, options.homeworlds && !normalCall ? 'homeworld:guild' : 'tueks_sietch:5', 1); + const location = options.homeworlds && !normalCall ? 'homeworld:guild' : 'tueks_sietch'; + g = openBattle(g, location); + g = advance(g, s => !!s.battle && clean(s) && !s.battle.preparation && s.battle.preLeader?.closed !== false, + undefined, s => s.decision?.kind === 'leaderSkillVisibility' && s.decision.player === guild && normalCall + ? { actor: guild, action: { type: 'leaderSkillVisibility', event: s.decision.event, hide: true } } : quiet(s)); + const own = player(g, f.moritani).leaders.filter(l => !l.dead && !l.usedAt && + !g.leaderSkills?.assignments.some(a => a.leader === l.id)).sort((a, b) => a.strength - b.strength)[0]; + const target = leaders('guild').find(l => l.name === 'Master Bewt')!.id; + const enemy = normalCall ? player(g, guild).leaders.find(l => l.id === target)! + : player(g, guild).leaders.filter(l => !l.dead && !l.usedAt && l.id !== target).sort((a, b) => b.strength - a.strength)[0]; + assert.ok(own && enemy && player(g, f.moritani).traitors.includes(target)); + const plans: PairedE3NexusStep[] = [ + { actor: f.moritani, action: { type: 'battlePlan', leader: own.id, dial: 0, support: 0, + weapon: player(g, f.moritani).hand.find(c => c.kind === 'worthless')?.id } }, + { actor: guild, action: { type: 'battlePlan', leader: enemy.id, dial: 2, support: g.advanced ? 2 : 0, + weapon: player(g, guild).hand.find(c => c.kind === 'worthless')?.id } }, + ]; + const revealed = reveal(g, plans), pending = settle(revealed, true, normalCall ? f.moritani : undefined); + return { f, guild, target, location, revealed, pending, enemy: enemy.id }; +} + +for (const skills of [false, true]) void test(`${skills ? 'all14' : 'No-Skills'} native assassination against Ecaz consumes an original different disc and printed bounty`, () => { + const f = programme({ advanced: true, skills, tech: true, assassinationFaction: 'ecaz' }); + const arrivals = new Set([f.ecaz, f.moritani]); + let g = reload(f.game); + while (arrivals.size) { + g = advance(g, s => s.turn === 2 && s.phase === 5 && clean(s) && arrivals.has(s.active!), undefined, quiet); + const actor = g.active!; + g = ship(g, actor, `rock_outcroppings:${territory('rock_outcroppings').sectors[0]}`, actor === f.ecaz ? 2 : 1).after; + arrivals.delete(actor); + if (arrivals.size) g = step(g, { actor, action: { type: 'endMovement' } }); + } + g = openBattle(g, 'rock_outcroppings'); + g = advance(g, s => !!s.battle && clean(s) && !s.battle.preparation && s.battle.preLeader?.closed !== false, undefined, quiet); + const ecaz = player(g, f.ecaz), target = ecaz.leaders.find(l => player(g, f.moritani).traitors.includes(l.id))!; + assert.ok(target && !target.dead); + const winner = ecaz.leaders.find(l => !l.dead && !l.usedAt && l.id !== target.id && + !g.leaderSkills?.assignments.some(a => a.leader === l.id))!; + const loser = player(g, f.moritani).leaders.filter(l => !l.dead && !l.usedAt && + !g.leaderSkills?.assignments.some(a => a.leader === l.id)).sort((a, b) => a.strength - b.strength)[0]; + assert.ok(winner && loser); + const revealed = reveal(g, [ + { actor: f.ecaz, action: { type: 'battlePlan', leader: winner.id, dial: 1, support: 1 } }, + { actor: f.moritani, action: { type: 'battlePlan', leader: loser.id, dial: 0, support: 0 } }, + ]); + const pending = settle(revealed, true); + assert.equal(pending.lastBattleContext!.winner, f.ecaz); + const before = reload(pending); + const killed = step(pending, { actor: f.moritani, action: { + type: 'decision', event: pending.lastBattleContext!.event, card: target.id, + } }); + assert.equal(player(killed, f.ecaz).leaders.find(l => l.id === target.id)!.dead, true); + assert.equal(player(killed, f.ecaz).leaders.find(l => l.id === target.id)!.deaths, target.deaths + 1); + assert.equal(player(killed, f.moritani).spice, player(before, f.moritani).spice + target.strength); + const done = settle(killed); + const later = advance(done, s => s.turn === 3 && s.phase === 1 && clean(s), undefined, quiet); + assert.equal(player(later, f.moritani).traitors.includes(target.id), false); + assert.equal(later.traitorReserve!.includes(target.id), false); + inventory(later); +}); + +void test('original paired E3 Basic/Advanced two-to-six-seat setup conserves ecaz33, cache0, all12 and optional all14', () => { + const factions: FactionId[] = ['ecaz', 'moritani', 'guild', 'emperor', 'atreides', 'fremen']; + for (const advanced of [false, true]) for (const skills of [false, true]) for (let count = 2; count <= 6; count++) { + const f = setup({ advanced, skills, roster: factions.slice(0, count), tech: count >= 3, strongholds: advanced }); + assert.equal(player(f.afterSetup, f.ecaz).leaders.length, 5); + assert.equal(player(f.afterSetup, f.moritani).leaders.length, 5); + assert.equal(player(f.afterSetup, f.ecaz).reserves, 14); + assert.equal(player(f.afterSetup, f.moritani).reserves, 14); + assert.equal(player(f.afterSetup, f.moritani).traitors.length, 1); + assert.equal(f.afterSetup.dukeVidal!.controller, null); + assert.equal(f.afterSetup.dukeVidal!.leader.dead, false); + assert.ok(!f.afterSetup.leaderSkills?.assignments.some(a => a.leader === DUKE_VIDAL_ID)); + assert.equal(f.afterSetup.moritaniTerror!.tokens.filter(t => t.status === 'available').length, 6); + inventory(reload(f.afterSetup)); + } + const basic = setup({ advanced: false, skills: true, roster: ['ecaz', 'moritani', 'harkonnen'] }); + assert.equal(player(basic.afterSetup, 'harkonnen').traitors.length, 4); inventory(basic.afterSetup); + let blocked = createGame('PAIRHARKGUARD', newPlayer('e', 'Ecaz', 'ecaz'), true, ['ecaz']); + joinGame(blocked, newPlayer('m', 'Moritani', 'moritani')); + joinGame(blocked, newPlayer('h', 'Harkonnen', 'harkonnen')); + for (const p of blocked.players) blocked = applyAction(blocked, p.id, { type: 'ready' }); + blocked.nexusCards = { cards: null, phase: null }; + const before = structuredClone(blocked); + assert.throws(() => initializePairedNexusGameForAudit(blocked)); + assert.deepEqual(blocked, before, 'Rejected capture composition cannot redeal original pieces.'); +}); + +void test('original paired E3 skill offers reject shared Duke and foreign training immutably', () => { + let lobby = createGame('ORIGINALPAIREDE3', newPlayer('original-e', 'Ecaz', 'ecaz'), true, ['ecaz']); + for (const faction of ['moritani', 'guild', 'emperor'] as const) joinGame(lobby, newPlayer(`original-${faction}`, faction, faction)); + lobby.nexusCards = { cards: null, phase: null }; + for (const p of lobby.players) lobby = applyAction(lobby, p.id, { type: 'ready' }); + const offered = initializeLeaderSkillsGameForAudit(lobby), actor = Object.keys(offered.leaderSkills!.offers)[0]; + const own = viewGame(offered, actor).leaderSkills!, skill = own.offer!.cards.find(c => !own.unavailableSkills?.[c]); assert.ok(skill); + reject(offered, actor, { type: 'leaderSkill', event: own.offer!.event, skill, leader: DUKE_VIDAL_ID }); + const foreignDisc = offered.players.find(p => p.id !== actor)!.leaders[0].id; + reject(offered, actor, { type: 'leaderSkill', event: own.offer!.event, skill, leader: foreignDisc }); +}); + +for (const skills of [false, true]) void test(`${skills ? 'all14' : 'No-Skills'} qualifying original Great Maker/both-pile closing deals both native Cunning singletons`, () => { + const f = programme({ advanced: true, skills, discovery: true, homeworlds: true, tech: true, strongholds: true, + roster: ['ecaz', 'moritani', 'guild', 'fremen'] }); + assert.ok(f.maker && f.alliance && f.drawing); + assert.equal(player(f.alliance, f.others[0]).ally, f.others[1]); + assert.equal(player(f.alliance, f.others[1]).ally, f.others[0]); + assert.equal(player(f.alliance, f.ecaz).ally, null); assert.equal(player(f.alliance, f.moritani).ally, null); + assert.ok(Object.values(f.afterSetup.nexusCards!.cards!.hands).every(c => c === null)); + assert.ok(f.drawing.spiceDiscard[0].some(c => 'territory' in c)); + assert.ok(f.drawing.spiceDiscard[1].some(c => 'territory' in c)); + assert.equal(f.game.nexusCards!.cards!.hands[f.ecaz], 'ecaz'); + assert.equal(f.game.nexusCards!.cards!.hands[f.moritani], 'moritani'); + for (const draw of f.draws) { + reject(draw.after, draw.step.actor, draw.step.action); + policies(draw.before, draw.step.actor, after => { + const original = draw.before.nexusCards!.cards!, current = after.nexusCards!.cards!; + const held = current.hands[draw.step.actor]; + assert.equal(current.deck.length, original.deck.length - (held === null ? 0 : 1)); + if (held !== null) assert.equal(current.deck.includes(held), false); + }); + } + assert.ok(f.firstMentat && f.claim); + assert.equal(f.game.strongholdCards!.owners.sietch_tabr, f.ecaz); + assert.equal(player(f.claim.after, f.ecaz).reserves, player(f.claim.before, f.ecaz).reserves - 1); + inventory(f.game); +}); + +for (const skills of [false, true]) void test(`${skills ? 'all14' : 'No-Skills'} quiet living-Duke Cunning is separate from training and allied Arrakis Occupy`, () => { + const f = programme({ skills, tech: true }); + let g = shipWhenActive(f.game, f.ecaz, 'tueks_sietch:5', 1); + g = advance(g, s => s.phase === 6 && clean(s) && !s.battle, undefined, quiet); + const action = nexusEcazDukeAction(viewGame(g, f.ecaz)); assert.ok(action); + reject(g, f.moritani, action); reject(g, f.ecaz, { ...action, event: 'stale' }); + policies(g, f.ecaz, after => { + assert.equal(after.dukeVidal!.controller, f.ecaz); assert.equal(after.dukeVidal!.leader.strength, 6); + assert.equal(after.nexusCards!.cards!.hands[f.ecaz], null); + assert.ok(!after.leaderSkills?.assignments.some(a => a.leader === DUKE_VIDAL_ID)); + assert.equal(player(after, f.ecaz).leaders.length, 5); + assert.equal(player(after, f.ecaz).spice, player(g, f.ecaz).spice); + }); + const claimed = step(g, { actor: f.ecaz, action }); + reject(claimed, f.ecaz, action); inventory(claimed); +}); + +for (const skills of [false, true]) for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic even-FORCE'} ${skills ? 'all14' : 'No-Skills'} coalition pays the selected leader, kills each physical owner and transfers only the loser Tech`, () => { + const f = programme({ skills, advanced, tech: true, strongholds: advanced }); + let g = advance(f.game, s => s.turn === 3 && s.phase === 1 && clean(s), undefined, quiet); + const at = g.spiceDeck.findIndex(c => 'worm' in c && !c.greatMaker && !c.suppressed); assert.ok(at >= 0); + g.spiceDeck.unshift(g.spiceDeck.splice(at, 1)[0]); + g = advance(g, s => !!s.nexus && !s.spiceWindow && !s.spiceResolution && clean(s), undefined, quiet); + const ally = f.others[1], opponent = f.others[0]; + g = step(g, { actor: ally, action: { type: 'alliance', target: null } }); + for (const [actor, target] of [[f.ecaz, ally], [ally, f.ecaz]]) g = step(g, { actor, action: { type: 'alliance', target } }); + const amounts: Record = { [f.ecaz]: advanced ? 4 : 3, [ally]: 4, [opponent]: 6 }; + const shipped = new Set(); + for (let n = 0; !(g.phase === 6 && clean(g)) && n < 600; n++) { + if (g.phase === 5 && clean(g) && g.active && amounts[g.active] && !shipped.has(g.active)) { + const actor = g.active, arrival = ship(g, actor, 'sietch_tabr:14', amounts[actor]); + assert.equal(player(arrival.after, actor).reserves, player(arrival.before, actor).reserves - amounts[actor]); + assert.equal(player(arrival.after, actor).forces['sietch_tabr:14'], + (player(arrival.before, actor).forces['sietch_tabr:14'] ?? 0) + amounts[actor]); + assert.ok(player(arrival.after, actor).spice < player(arrival.before, actor).spice); + shipped.add(actor); g = arrival.after; + } else { + const next = nextPairedE3NexusStep(g) ?? quiet(g); assert.ok(next); g = step(g, next); + } + } + assert.equal(g.phase, 6); assert.equal(shipped.size, 3); + const opened = openBattle(g, 'sietch_tabr'), d = opened.decision; assert.ok(d?.kind === 'ecazBattleLead'); + reject(opened, ally, { type: 'decision', event: d.event, lead: f.ecaz }); + reject(opened, f.ecaz, { type: 'decision', event: d.event, lead: opponent }); + const run = (state: Game, lead: string) => { + state = advance(state, s => !!s.battle && clean(s) && !s.battle.preparation && s.battle.preLeader?.closed !== false, + undefined, s => s.decision?.kind === 'leaderSkillVisibility' && s.decision.player === lead + ? { actor: lead, action: { type: 'leaderSkillVisibility', event: s.decision.event, hide: true } } : quiet(s)); + const profile = viewGame(state, lead).battle!.ecazOccupy!.profile!; + const assignment = state.leaderSkills?.assignments.find(a => a.owner === lead); + const own = assignment ? player(state, lead).leaders.find(l => l.id === assignment.leader)! + : player(state, lead).leaders.filter(l => !l.dead).sort((a, b) => b.strength - a.strength)[0]; + const enemy = player(state, opponent).leaders.filter(l => !l.dead && + !state.leaderSkills?.assignments.some(a => a.leader === l.id)).sort((a, b) => a.strength - b.strength)[0]; + const revealed = reveal(state, [ + { actor: lead, action: { type: 'battlePlan', leader: own.id, dial: profile.fixedEcazDial + 3, support: advanced ? 3 : 0, + weapon: player(state, lead).hand.find(c => c.kind === 'worthless')?.id } }, + { actor: opponent, action: { type: 'battlePlan', leader: enemy.id, dial: 1, support: advanced ? 1 : 0 } }, + ]), q = quote(revealed), done = settle(revealed); + assert.equal(q.winner, lead); assert.equal(done.lastBattleContext!.winner, lead); + if (skills) { + const selected = revealed.battle!.attacker === lead ? q.leaderSkillBonuses.attacker : q.leaderSkillBonuses.defender; + assert.equal(selected.bonus, lead === f.ecaz ? 3 : 0, + 'Only the actual selected Warmaster disc with its original Worthless gets +3; the ally cannot borrow that training'); + } + const holder = revealed.strongholdCards?.owners.sietch_tabr; + assert.deepEqual(q.strongholdIncome, advanced && holder === lead ? [{ player: lead, amount: 1 }] : []); + const fixed = advanced ? 3 : 2; + assert.equal(player(done, f.ecaz).tanks - player(revealed, f.ecaz).tanks, fixed); + assert.equal(player(done, ally).tanks - player(revealed, ally).tanks, 3); + assert.equal(player(done, opponent).tanks - player(revealed, opponent).tanks, 6); + for (const payment of q.payments) { + const income = q.strongholdIncome.filter(i => i.player === payment.player).reduce((a, i) => a + i.amount, 0); + assert.equal(player(done, payment.player).spice, player(revealed, payment.player).spice - payment.ownPayment + income + collectionIncome(done, payment.player)); + } + assert.equal(done.techTokens![ownedTech(revealed.techTokens, opponent)[0]].owner, lead); + for (const original of revealed.players.filter(p => p.id !== opponent)) + for (const token of ownedTech(revealed.techTokens, original.id)) + assert.equal(done.techTokens![token].owner, original.id, 'Only actual loser tokens transfer.'); + inventory(done); + }; + policies(opened, f.ecaz, (after, action) => run(after, String(action.lead))); + for (const lead of [f.ecaz, ally]) run(step(opened, { actor: f.ecaz, action: { type: 'decision', event: d.event, lead } }), lead); +}); + +void test('native all14 post-loss assassination waits for real winner casualties, Suk effects and support, then pays printed death bounty and replaces privately at actual Mentat', () => { + const b = guildLoss({ skills: true, advanced: true, tech: true, strongholds: true }); + const { pending, revealed, f, guild, target } = b, q = quote(revealed); + assert.equal(q.winner, guild); assert.ok(pending.decision?.kind === 'moritaniAssassinate'); + assert.equal(player(pending, f.moritani).tanks, player(revealed, f.moritani).tanks + 1); + assert.equal(player(pending, guild).tanks, player(revealed, guild).tanks + 1); + assert.equal(player(pending, guild).reserves, player(revealed, guild).reserves + 1); + const income = q.strongholdIncome.filter(i => i.player === guild).reduce((a, i) => a + i.amount, 0); + assert.equal(player(pending, guild).spice, player(revealed, guild).spice - 2 + income); + assert.equal(pending.techTokens![ownedTech(revealed.techTokens, f.moritani)[0]].owner, f.moritani, + 'Assassination pauses the winner suffix before the original automatic single-token transfer'); + const d = pending.decision; assert.ok(d?.kind === 'moritaniAssassinate'); + const action: Action = { type: 'decision', event: d.event, card: target }; + reject(pending, guild, action); reject(pending, f.moritani, { ...action, card: b.enemy }); + const beforeTarget = player(pending, guild).leaders.find(l => l.id === target)!; + let killed = step(pending, { actor: f.moritani, action }); + assert.equal(player(killed, guild).leaders.find(l => l.id === target)!.dead, true); + assert.equal(player(killed, guild).leaders.find(l => l.id === target)!.deaths, beforeTarget.deaths + 1); + assert.equal(player(killed, f.moritani).spice, player(pending, f.moritani).spice + beforeTarget.strength); + assert.ok(!killed.leaderSkills!.assignments.some(a => a.leader === target)); + assert.equal(killed.leaderSkills!.deck.filter(c => c === 'suk-graduate').length, 1); + killed = settle(killed); + assert.equal(killed.techTokens![ownedTech(revealed.techTokens, f.moritani)[0]].owner, guild); + const retired = target; + const later = advance(killed, s => s.turn === 3 && s.phase === 1 && clean(s), undefined, quiet); + assert.ok(!player(later, f.moritani).traitors.includes(retired)); + assert.equal(player(later, f.moritani).traitors.length, 1); + assert.ok(!later.traitorReserve!.includes(retired)); + assert.equal(later.moritaniAssassinate!.opportunities.filter(r => r.card === retired && r.stage === 'replaced').length, 1); + inventory(later); + policies(pending, f.moritani, after => { + const finished = settle(after); assert.equal(finished.lastBattleContext!.winner, guild); + assert.equal(finished.techTokens![ownedTech(revealed.techTokens, f.moritani)[0]].owner, guild); + const dead = player(after, guild).leaders.find(l => l.id === target)!.dead; + assert.equal(player(after, f.moritani).spice, player(pending, f.moritani).spice + (dead ? beforeTarget.strength : 0)); + assert.equal(player(finished, guild).spice, player(pending, guild).spice + collectionIncome(finished, guild)); + }); +}); + +for (const skills of [false, true]) void test(`${skills ? 'all14' : 'No-Skills'} real Arrakis normal Traitor call forfeits native assassination for the rest of the game`, () => { + const b = guildLoss({ skills, advanced: true, tech: true }, true); + assert.equal(b.pending.moritaniAssassinate!.normalTraitorCall, true); + assert.equal(b.pending.lastBattleContext!.winner, b.f.moritani); + assert.ok(!b.pending.moritaniAssassinate!.opportunities.some(r => r.stage === 'choice')); + inventory(b.pending); +}); + +for (const skills of [false, true]) void test(`${skills ? 'all14' : 'No-Skills'} foreign Homeworld excludes normal Traitor calls but retains separate native assassination`, () => { + const b = guildLoss({ skills, advanced: true, tech: true, homeworlds: true }); + reject(b.revealed, b.f.moritani, { type: 'traitorCall', call: true }); + assert.equal(b.pending.lastBattleContext!.winner, b.guild); + const before = reload(b.pending), target = player(before, b.guild).leaders.find(l => l.id === b.target)!; + const action: Action = { type: 'decision', event: before.lastBattleContext!.event, card: b.target }; + const killed = step(before, { actor: b.f.moritani, action }); + assert.equal(player(killed, b.guild).leaders.find(l => l.id === b.target)!.dead, true); + assert.equal(player(killed, b.guild).leaders.find(l => l.id === b.target)!.deaths, target.deaths + 1); + assert.equal(player(killed, b.f.moritani).spice, player(before, b.f.moritani).spice + target.strength); + const done = settle(killed); + const home = homeworldForceGroups(homeworldContext(done), done.homeworlds!.custody!).find(h => h.id === 'homeworld:guild')!; + assert.equal(home.forces[b.f.moritani]?.normal ?? 0, 0); + assert.equal(player(done, b.f.moritani).tanks - player(b.revealed, b.f.moritani).tanks, 1); + inventory(done); +}); + +for (const skills of [false, true]) void test(`${skills ? 'all14' : 'No-Skills'} supply-only Moritani Cunning uses actual placement and entrant reveal after original Grumman declines`, () => { + const f = programme({ skills, advanced: true, tech: true }); + let g = advance(f.game, s => !s.response && !s.phaseOpening && s.decision?.kind === 'moritaniPlacement', undefined, quiet); + const token = g.moritaniTerror!.tokens.find(t => t.kind === 'robbery' && t.status === 'available'); assert.ok(token); + const offer = viewGame(g, f.moritani).nexusMoritani; assert.ok(offer); + const action: Action = { type: 'decision', token: token.id, territory: 'sihaya_ridge', nexus: offer.event }; + policies(g, f.moritani, after => { + const allowed = advance(after, s => !s.response && !s.phaseOpening, undefined, quiet); + assert.equal(player(allowed, f.moritani).spice, player(g, f.moritani).spice); + assert.equal(player(allowed, f.moritani).reserves, player(g, f.moritani).reserves); + assert.equal(allowed.moritaniTerror!.tokens.length, 6); + assert.ok(allowed.moritaniTerror!.tokens.filter(t => t.status === 'placed').every(t => + t.location !== 'grumman' && !t.location?.startsWith('homeworld:'))); + }); + reject(g, f.ecaz, action); reject(g, f.moritani, { ...action, territory: 'grumman' }); + reject(g, f.moritani, { ...action, territory: 'hidden_mobile_stronghold' }); + reject(g, f.moritani, { ...action, territory: 'homeworld:guild' }); + const before = reload(g); + g = step(g, { actor: f.moritani, action }); + g = advance(g, s => !s.response && !s.phaseOpening, undefined, quiet); + assert.equal(g.moritaniTerror!.tokens.find(t => t.id === token.id)!.status, 'placed'); + assert.equal(g.moritaniTerror!.tokens.find(t => t.id === token.id)!.location, 'sihaya_ridge'); + assert.equal(g.nexusCards!.cards!.hands[f.moritani], null); + assert.equal(player(g, f.moritani).spice, player(before, f.moritani).spice); + assert.equal(player(g, f.moritani).reserves, player(before, f.moritani).reserves); + const guild = f.others[0]; + g = advance(g, s => s.turn === 3 && s.phase === 5 && s.active === guild && clean(s), undefined, quiet); + const shipped = step(g, { actor: guild, action: { type: 'ship', territory: 'sihaya_ridge', sector: 9, amount: 1, allyPayment: 0 } }); + g = advance(shipped, s => !s.response && !s.phaseOpening && s.decision?.kind === 'moritaniTerror', undefined, quiet); + const d = g.decision; assert.ok(d?.kind === 'moritaniTerror'); + const moriBefore = player(g, f.moritani).spice, entrantBefore = player(g, guild).spice; + const revealAction: Action = { type: 'decision', reveal: true }; + reject(g, guild, revealAction); + g = step(g, { actor: f.moritani, action: revealAction }); + g = step(g, { actor: f.moritani, action: { type: 'decision', choice: 'spice' } }); + g = advance(g, s => !s.response && !s.phaseOpening && !s.pendingTerrorEntry && clean(s), undefined, quiet); + assert.equal(g.moritaniTerror!.tokens.find(t => t.id === token.id)!.status, 'removed'); + assert.ok(player(g, f.moritani).spice > moriBefore); + assert.equal(player(g, f.moritani).spice - moriBefore, entrantBefore - player(g, guild).spice); + assert.equal(player(g, f.moritani).spice - moriBefore, Math.ceil(entrantBefore / 2)); + inventory(g); +}); + +void test('No-Skills original assassination pause retains the unfinished winner suffix, which settles every physical loss and payment exactly once', () => { + const b = guildLoss({ advanced: true, skills: false, tech: true, strongholds: true }); + const q = quote(b.revealed), d = b.pending.decision; assert.ok(d?.kind === 'moritaniAssassinate'); + const target = player(b.pending, b.guild).leaders.find(l => l.id === b.target)!; + const acted = step(b.pending, { actor: b.f.moritani, action: { type: 'decision', event: d.event, card: b.target } }); + const done = settle(acted); + assert.equal(player(done, b.f.moritani).tanks - player(b.revealed, b.f.moritani).tanks, 1); + assert.equal(player(done, b.guild).tanks - player(b.revealed, b.guild).tanks, 2); + assert.equal(player(done, b.guild).forces['tueks_sietch:5'], 3); + assert.equal(player(done, b.guild).leaders.find(l => l.id === b.target)!.dead, true); + for (const payment of q.payments) { + const income = q.strongholdIncome.filter(i => i.player === payment.player).reduce((a, i) => a + i.amount, 0); + assert.equal(player(done, payment.player).spice, player(b.revealed, payment.player).spice - payment.ownPayment + + income + (payment.player === b.f.moritani ? target.strength : 0) + collectionIncome(done, payment.player)); + } + assert.equal(done.techTokens![ownedTech(b.revealed.techTokens, b.f.moritani)[0]].owner, b.guild); + inventory(done); + const later = advance(done, s => s.turn === 3 && s.phase === 1 && clean(s), undefined, quiet); + assert.ok(!player(later, b.f.moritani).traitors.includes(b.target)); + assert.equal(player(later, b.f.moritani).traitors.length, 1); + assert.equal(later.moritaniAssassinate!.opportunities.filter(r => r.card === b.target && r.stage === 'replaced').length, 1); + inventory(later); +}); + +void test('real Basic odd Ecaz FORCE remains unresolved before coalition lead, any debit, casualties or rewards', () => { + const f = programme({ advanced: false, skills: false, tech: true }); + let g = advance(f.game, s => s.turn === 3 && s.phase === 1 && clean(s), undefined, quiet); + const at = g.spiceDeck.findIndex(c => 'worm' in c && !c.greatMaker && !c.suppressed); assert.ok(at >= 0); + g.spiceDeck.unshift(g.spiceDeck.splice(at, 1)[0]); + g = advance(g, s => !!s.nexus && !s.spiceWindow && !s.spiceResolution && clean(s), undefined, quiet); + const ally = f.others[1], opponent = f.others[0]; + g = step(g, { actor: ally, action: { type: 'alliance', target: null } }); + for (const [actor, target] of [[f.ecaz, ally], [ally, f.ecaz]]) g = step(g, { actor, action: { type: 'alliance', target } }); + const amounts: Record = { [f.ecaz]: 2, [ally]: 2, [opponent]: 2 }, shipped = new Set(); + for (let n = 0; !(g.phase === 6 && clean(g)) && n < 600; n++) { + if (g.phase === 5 && clean(g) && g.active && amounts[g.active] && !shipped.has(g.active)) { + const actor = g.active; g = ship(g, actor, 'sietch_tabr:14', amounts[actor]).after; shipped.add(actor); + } else { + const next = nextPairedE3NexusStep(g) ?? quiet(g); assert.ok(next); g = step(g, next); + } + } + assert.equal(player(g, f.ecaz).forces['sietch_tabr:14'], 3); + const choice = viewGame(g, g.active!).battleChoices.find(c => c.territory === 'sietch_tabr'); assert.ok(choice); + reject(g, choice.chooser, { type: 'chooseBattle', territory: 'sietch_tabr', + target: choice.attacker === choice.chooser ? choice.defender : choice.attacker }, /Odd-force Basic Occupy/); + assert.equal(g.battle, null); inventory(g); +}); diff --git a/tests/sample-custody.test.ts b/tests/sample-custody.test.ts index 4c979b89..b4e6ce52 100644 --- a/tests/sample-custody.test.ts +++ b/tests/sample-custody.test.ts @@ -20,6 +20,7 @@ import { stageAssassinationBattle, } from './moritani-assassinate-fixture'; import { createStrongholdFactionsFixture } from './fixture-stronghold-factions'; +import { createPairedE3NexusSetup } from './fixture-paired-e3-nexus-modules'; void test('Moritani Skills sample custody checks the physical skill and Traitor inventories after genuine setup', () => { const game = completedMoritaniSkillsGame(); @@ -37,6 +38,22 @@ void test('Moritani Skills sample custody checks the physical skill and Traitor assert.throws(() => verifySampleCustody(traitors, inventory), /traitor custody/); }); +for (const skills of [false, true]) void test(`paired native Ecaz loyalty remains one physical Traitor with ${skills ? 'all14 Skills' : 'no Skills'}`, () => { + const fixture = createPairedE3NexusSetup({ skills, homeworlds: false, discovery: false, tech: false, strongholds: false }); + const inventory = sampleInventory(fixture.offered); + verifySampleCustody(fixture.afterSetup, inventory); + const loyaltyCard = fixture.afterSetup.ecazLoyalty?.card; + assert.ok(loyaltyCard); + + const missing = structuredClone(fixture.afterSetup); + delete missing.ecazLoyalty; + assert.throws(() => verifySampleCustody(missing, inventory)); + + const duplicated = structuredClone(fixture.afterSetup); + duplicated.traitorReserve!.push(loyaltyCard); + assert.throws(() => verifySampleCustody(duplicated, inventory)); +}); + void test('Moritani assassination samples conserve retired Traitors after real battle and Mentat replacement', () => { const start = assassinationGame(); const inventory = sampleInventory(start); diff --git a/tools/faction-games.ts b/tools/faction-games.ts index 474af83a..a2b1482e 100644 --- a/tools/faction-games.ts +++ b/tools/faction-games.ts @@ -4,7 +4,7 @@ import { readFileSync, statSync, writeFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { parseArgs } from 'node:util'; -import { classicNexusModulesProfile, pairedNexusModulesProfile, singleE1E2NexusProfile, standaloneE3NexusProfile } from '../game/nexus-module-profile'; +import { classicNexusModulesProfile, mixedE1E2NexusProfile, pairedE3NexusProfile, pairedNexusModulesProfile, singleE1E2NexusProfile, standaloneE3NexusProfile } from '../game/nexus-module-profile'; import { nativeFactionTechProfile } from '../game/faction-module-profile'; import { classicDiscoveryLeaderSkillsProfile } from '../game/discovery-module-profile'; import { botActions } from '../game/bots'; @@ -97,6 +97,24 @@ const SINGLE_NATIVE_NEXUS_MODULE_PROFILES = [ 'single-homeworld-nexus-discovery', 'single-homeworld-nexus-discovery-tech', 'single-homeworld-nexus-discovery-stronghold', 'single-homeworld-nexus-discovery-stronghold-tech', 'single-homeworld-nexus-discovery-skills', 'single-homeworld-nexus-discovery-skills-tech', 'single-homeworld-nexus-discovery-skills-stronghold', 'single-homeworld-nexus-discovery-skills-stronghold-tech', ] as const; +const MIXED_PAIRED_NEXUS_MODULE_PROFILES = [ + 'mixed-nexus', 'mixed-nexus-tech', 'mixed-nexus-stronghold', 'mixed-nexus-stronghold-tech', + 'mixed-nexus-skills', 'mixed-nexus-skills-tech', 'mixed-nexus-skills-stronghold', 'mixed-nexus-skills-stronghold-tech', + 'mixed-discovery-nexus', 'mixed-discovery-nexus-tech', 'mixed-discovery-nexus-stronghold', 'mixed-discovery-nexus-stronghold-tech', + 'mixed-discovery-nexus-skills', 'mixed-discovery-nexus-skills-tech', 'mixed-discovery-nexus-skills-stronghold', 'mixed-discovery-nexus-skills-stronghold-tech', + 'mixed-homeworld-nexus', 'mixed-homeworld-nexus-tech', 'mixed-homeworld-nexus-stronghold', 'mixed-homeworld-nexus-stronghold-tech', + 'mixed-homeworld-nexus-skills', 'mixed-homeworld-nexus-skills-tech', 'mixed-homeworld-nexus-skills-stronghold', 'mixed-homeworld-nexus-skills-stronghold-tech', + 'mixed-homeworld-nexus-discovery', 'mixed-homeworld-nexus-discovery-tech', 'mixed-homeworld-nexus-discovery-stronghold', 'mixed-homeworld-nexus-discovery-stronghold-tech', + 'mixed-homeworld-nexus-discovery-skills', 'mixed-homeworld-nexus-discovery-skills-tech', 'mixed-homeworld-nexus-discovery-skills-stronghold', 'mixed-homeworld-nexus-discovery-skills-stronghold-tech', + 'paired-e3-nexus', 'paired-e3-nexus-tech', 'paired-e3-nexus-stronghold', 'paired-e3-nexus-stronghold-tech', + 'paired-e3-nexus-skills', 'paired-e3-nexus-skills-tech', 'paired-e3-nexus-skills-stronghold', 'paired-e3-nexus-skills-stronghold-tech', + 'paired-e3-discovery-nexus', 'paired-e3-discovery-nexus-tech', 'paired-e3-discovery-nexus-stronghold', 'paired-e3-discovery-nexus-stronghold-tech', + 'paired-e3-discovery-nexus-skills', 'paired-e3-discovery-nexus-skills-tech', 'paired-e3-discovery-nexus-skills-stronghold', 'paired-e3-discovery-nexus-skills-stronghold-tech', + 'paired-e3-homeworld-nexus', 'paired-e3-homeworld-nexus-tech', 'paired-e3-homeworld-nexus-stronghold', 'paired-e3-homeworld-nexus-stronghold-tech', + 'paired-e3-homeworld-nexus-skills', 'paired-e3-homeworld-nexus-skills-tech', 'paired-e3-homeworld-nexus-skills-stronghold', 'paired-e3-homeworld-nexus-skills-stronghold-tech', + 'paired-e3-homeworld-nexus-discovery', 'paired-e3-homeworld-nexus-discovery-tech', 'paired-e3-homeworld-nexus-discovery-stronghold', 'paired-e3-homeworld-nexus-discovery-stronghold-tech', + 'paired-e3-homeworld-nexus-discovery-skills', 'paired-e3-homeworld-nexus-discovery-skills-tech', 'paired-e3-homeworld-nexus-discovery-skills-stronghold', 'paired-e3-homeworld-nexus-discovery-skills-stronghold-tech', +] as const; type Profile = 'base' | 'choam' | 'ecaz' | 'ecaz-treachery' | 'ecaz-occupy' | 'moritani-assassinate' | 'stronghold-factions' | 'combined' | 'combined-nexus' | 'combined-homeworld-nexus' | 'homeworld' | 'nexus' | 'homeworld-nexus' | 'choam-roster' | 'ecaz-roster' | 'ix-roster' | 'choam-nexus' | 'ecaz-nexus' | 'ix-nexus' | 'moritani-skills' | 'tleilaxu-skills' | 'ix-skills' | 'choam-skills' | 'richese-skills' | 'ecaz-skills' | 'skills-tech' | 'skills-stronghold' | 'skills-stronghold-tech' | 'factions-tech' | 'stronghold-factions-tech' | 'nexus-tech' | 'nexus-stronghold' | 'nexus-stronghold-tech' | 'paired-nexus-tech' | 'paired-nexus-stronghold' | 'paired-nexus-stronghold-tech' | 'nexus-skills' | 'paired-nexus-skills' @@ -115,7 +133,8 @@ type Profile = 'base' | 'choam' | 'ecaz' | 'ecaz-treachery' | 'ecaz-occupy' | 'm | (typeof HOMEWORLD_NEXUS_MODULE_PROFILES)[number] | (typeof HOMEWORLD_NATIVE_NEXUS_MODULE_PROFILES)[number] | (typeof E3_NEXUS_MODULE_PROFILES)[number] - | (typeof SINGLE_NATIVE_NEXUS_MODULE_PROFILES)[number]; + | (typeof SINGLE_NATIVE_NEXUS_MODULE_PROFILES)[number] + | (typeof MIXED_PAIRED_NEXUS_MODULE_PROFILES)[number]; const discoveryStrongholdProfile = (profile: string) => profile === 'discovery-stronghold' || profile === 'discovery-stronghold-tech' || profile === 'discovery-skills-stronghold' || profile === 'discovery-skills-stronghold-tech' || profile === 'native-discovery-skills-stronghold' || @@ -138,6 +157,10 @@ const nativeDiscoveryNexusSkillsProfile = (profile: string) => profile === 'paired-discovery-nexus-skills-stronghold' || profile === 'paired-discovery-nexus-skills-stronghold-tech' || profile === 'e3-discovery-nexus-skills' || profile === 'e3-discovery-nexus-skills-tech' || profile === 'e3-discovery-nexus-skills-stronghold' || profile === 'e3-discovery-nexus-skills-stronghold-tech'; +const mixedPairedNexusSampleProfile = (profile: string) => + (MIXED_PAIRED_NEXUS_MODULE_PROFILES as readonly string[]).includes(profile); +const pairedE3NexusSampleProfile = (profile: string) => + mixedPairedNexusSampleProfile(profile) && profile.startsWith('paired-e3-'); const singleNativeNexusSampleProfile = (profile: string) => (SINGLE_NATIVE_NEXUS_MODULE_PROFILES as readonly string[]).includes(profile); const e3NexusSampleProfile = (profile: string) => @@ -145,7 +168,7 @@ const e3NexusSampleProfile = (profile: string) => const nativeHomeworldNexusSampleProfile = (profile: string) => (HOMEWORLD_NATIVE_NEXUS_MODULE_PROFILES as readonly string[]).includes(profile); const homeworldNexusSampleProfile = (profile: string) => - ((e3NexusSampleProfile(profile) || singleNativeNexusSampleProfile(profile)) && profile.includes('-homeworld-')) || + ((e3NexusSampleProfile(profile) || singleNativeNexusSampleProfile(profile) || mixedPairedNexusSampleProfile(profile)) && profile.includes('-homeworld-')) || (HOMEWORLD_NEXUS_MODULE_PROFILES as readonly string[]).includes(profile) || nativeHomeworldNexusSampleProfile(profile); const homeworldModuleSampleProfile = (profile: string) => (HOMEWORLD_MODULE_PROFILES as readonly string[]).includes(profile) || homeworldNexusSampleProfile(profile); @@ -293,16 +316,16 @@ const CLASSIC_NEXUS_MODULE_SCENARIOS: readonly Scenario[] = (profile === 'nexus-stronghold' || scenario.roster.length >= 3)) .map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 800 + band * 100 + index }))); const discoveryNexusProfile = (profile: string) => - ((e3NexusSampleProfile(profile) || singleNativeNexusSampleProfile(profile)) && profile.includes('-discovery')) || profile === 'discovery-nexus' || + ((e3NexusSampleProfile(profile) || singleNativeNexusSampleProfile(profile) || mixedPairedNexusSampleProfile(profile)) && profile.includes('-discovery')) || profile === 'discovery-nexus' || profile === 'discovery-nexus-tech' || profile === 'discovery-nexus-stronghold' || profile === 'discovery-nexus-stronghold-tech' || discoveryNexusSkillsProfile(profile) || pairedDiscoveryNexusSampleProfile(profile) || nativeDiscoveryNexusSkillsProfile(profile); const nexusModuleProfile = (profile: string) => - singleNativeNexusSampleProfile(profile) || e3NexusSampleProfile(profile) || homeworldNexusSampleProfile(profile) || + mixedPairedNexusSampleProfile(profile) || singleNativeNexusSampleProfile(profile) || e3NexusSampleProfile(profile) || homeworldNexusSampleProfile(profile) || profile === 'nexus-tech' || profile === 'nexus-stronghold' || profile === 'nexus-stronghold-tech' || discoveryNexusProfile(profile) || pairedNexusModuleProfile(profile) || nexusSkillsModuleProfile(profile) || pairedNexusSkillsModuleProfile(profile) || e3NexusSkillsProfile(profile); const nexusTechProfile = (profile: string) => - ((e3NexusSampleProfile(profile) || singleNativeNexusSampleProfile(profile)) && profile.endsWith('-tech')) || + ((e3NexusSampleProfile(profile) || singleNativeNexusSampleProfile(profile) || mixedPairedNexusSampleProfile(profile)) && profile.endsWith('-tech')) || ((discoveryNexusSkillsProfile(profile) || pairedDiscoveryNexusSampleProfile(profile) || nativeDiscoveryNexusSkillsProfile(profile)) && profile.endsWith('tech')) || profile === 'discovery-nexus-tech' || profile === 'discovery-nexus-stronghold-tech' || profile === 'nexus-tech' || profile === 'nexus-stronghold-tech' || profile === 'paired-nexus-tech' || profile === 'paired-nexus-stronghold-tech' || @@ -310,7 +333,7 @@ const nexusTechProfile = (profile: string) => profile === 'paired-nexus-skills-tech' || profile === 'paired-nexus-skills-stronghold-tech' || profile === 'e3-nexus-skills-tech' || profile === 'e3-nexus-skills-stronghold-tech'; const nexusStrongholdProfile = (profile: string) => - ((e3NexusSampleProfile(profile) || singleNativeNexusSampleProfile(profile)) && profile.includes('stronghold')) || + ((e3NexusSampleProfile(profile) || singleNativeNexusSampleProfile(profile) || mixedPairedNexusSampleProfile(profile)) && profile.includes('stronghold')) || ((discoveryNexusSkillsProfile(profile) || pairedDiscoveryNexusSampleProfile(profile) || nativeDiscoveryNexusSkillsProfile(profile)) && profile.includes('stronghold')) || profile === 'discovery-nexus-stronghold' || profile === 'discovery-nexus-stronghold-tech' || profile === 'nexus-stronghold' || profile === 'nexus-stronghold-tech' || profile === 'paired-nexus-stronghold' || profile === 'paired-nexus-stronghold-tech' || @@ -606,6 +629,28 @@ const SINGLE_NATIVE_NEXUS_MODULE_SCENARIOS: readonly Scenario[] = .filter(scenario => (!nexusStrongholdProfile(profile) || scenario.rules === 'advanced') && (!nexusTechProfile(profile) || scenario.roster.length >= 3)) .map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 14200 + band * 100 + index }))); +const MIXED_NEXUS_BASE_SCENARIOS: readonly Scenario[] = + (['ixians', 'tleilaxu'] as const).flatMap(ix => + (['choam', 'richese'] as const).flatMap(choam => + (['basic', 'advanced'] as const).flatMap(rules => [2, 3, 4, 5, 6].map((players): Scenario => ({ + profile: 'mixed-nexus', ordinal: 0, rules, expansions: ['ix', 'choam'], + roster: ([ix, choam, 'emperor', 'guild', 'fremen', 'beneGesserit'] as FactionId[]).slice(0, players), + }))))).concat( + (['basic', 'advanced'] as const).flatMap(rules => [4, 5, 6].map((players): Scenario => ({ + profile: 'mixed-nexus', ordinal: 0, rules, expansions: ['ix', 'choam'], + roster: (['ixians', 'tleilaxu', 'choam', 'richese', 'guild', 'fremen'] as FactionId[]).slice(0, players), + })))); +const PAIRED_E3_NEXUS_BASE_SCENARIOS: readonly Scenario[] = + (['basic', 'advanced'] as const).flatMap(rules => [2, 3, 4, 5, 6].map((players): Scenario => ({ + profile: 'paired-e3-nexus', ordinal: 0, rules, expansions: ['ecaz'], + roster: (['ecaz', 'moritani', 'emperor', 'guild', 'fremen', 'beneGesserit'] as FactionId[]).slice(0, players), + }))); +const MIXED_PAIRED_NEXUS_MODULE_SCENARIOS: readonly Scenario[] = + MIXED_PAIRED_NEXUS_MODULE_PROFILES.flatMap((profile, band) => + (pairedE3NexusSampleProfile(profile) ? PAIRED_E3_NEXUS_BASE_SCENARIOS : MIXED_NEXUS_BASE_SCENARIOS) + .filter(scenario => (!nexusStrongholdProfile(profile) || scenario.rules === 'advanced') && + (!nexusTechProfile(profile) || scenario.roster.length >= 3)) + .map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 17400 + band * 100 + index }))); const STRONGHOLD_FACTIONS_ROSTER: readonly FactionId[] = [ 'ixians', 'choam', 'emperor', 'fremen', 'harkonnen', 'beneGesserit', ]; @@ -684,14 +729,14 @@ const SKILLS_STRONGHOLD_SCENARIOS: readonly Scenario[] = const strongholdSkillProfile = (profile: string) => profile === 'skills-stronghold' || profile === 'skills-stronghold-tech'; const discoverySkillsProfile = (profile: string) => profile === 'discovery-skills' || profile === 'discovery-skills-tech' || profile === 'discovery-skills-stronghold' || profile === 'discovery-skills-stronghold-tech'; -const skillProfile = (profile: string) => (singleNativeNexusSampleProfile(profile) && profile.includes('-skills')) || +const skillProfile = (profile: string) => ((singleNativeNexusSampleProfile(profile) || mixedPairedNexusSampleProfile(profile)) && profile.includes('-skills')) || homeworldSkillsSampleProfile(profile) || nativeDiscoveryNexusSkillsProfile(profile) || discoveryNexusSkillsProfile(profile) || nativeDiscoverySkillsProfile(profile) || discoverySkillsProfile(profile) || e3NexusSkillsProfile(profile) || pairedNexusSkillsModuleProfile(profile) || nexusSkillsModuleProfile(profile) || profile === 'nexus-skills' || profile === 'paired-nexus-skills' || profile === 'moritani-skills' || profile === 'tleilaxu-skills' || profile === 'ix-skills' || profile === 'choam-skills' || profile === 'richese-skills' || profile === 'ecaz-skills' || profile === 'skills-tech' || strongholdSkillProfile(profile); const discoveryProfile = (profile: string) => profile === 'discovery' || profile === 'discovery-tech' || profile === 'discovery-stronghold' || profile === 'discovery-stronghold-tech' || (homeworldDiscoverySampleProfile(profile) && !homeworldSkillsSampleProfile(profile)); const assassinationScenario = (scenario: Scenario) => scenario.rules === 'advanced' && scenario.roster.includes('moritani') && - (e3NexusSampleProfile(scenario.profile) || scenario.profile === 'moritani-assassinate' || discoveryProfile(scenario.profile) || nativeStrongholdProfile(scenario.profile) || nativeTechProfile(scenario.profile) || skillProfile(scenario.profile)); + (pairedE3NexusSampleProfile(scenario.profile) || e3NexusSampleProfile(scenario.profile) || scenario.profile === 'moritani-assassinate' || discoveryProfile(scenario.profile) || nativeStrongholdProfile(scenario.profile) || nativeTechProfile(scenario.profile) || skillProfile(scenario.profile)); type TraceEntry = { attempt: number; @@ -730,9 +775,10 @@ function usage() { return ( 'Usage: node --import tsx tools/faction-games.ts --out NEW_PRIVATE_DIR ' + '[--seed UINT32] [--profile all|base|discovery|discovery-tech|discovery-skills|discovery-skills-tech|discovery-nexus|discovery-nexus-tech|discovery-nexus-stronghold|discovery-nexus-stronghold-tech|choam|ecaz|ecaz-treachery|ecaz-occupy|moritani-assassinate|stronghold-factions|factions-tech|stronghold-factions-tech|combined|combined-nexus|combined-homeworld-nexus|homeworld|homeworld-occupation|nexus|nexus-skills|paired-nexus-skills|nexus-skills-tech|nexus-skills-stronghold|nexus-skills-stronghold-tech|paired-nexus-skills-tech|paired-nexus-skills-stronghold|paired-nexus-skills-stronghold-tech|e3-nexus-skills|e3-nexus-skills-tech|e3-nexus-skills-stronghold|e3-nexus-skills-stronghold-tech|nexus-tech|nexus-stronghold|nexus-stronghold-tech|paired-nexus-tech|paired-nexus-stronghold|paired-nexus-stronghold-tech|homeworld-nexus|choam-roster|ecaz-roster|ix-roster|choam-nexus|ecaz-nexus|ix-nexus|moritani-skills|tleilaxu-skills|ix-skills|choam-skills|richese-skills|ecaz-skills|skills-tech|skills-stronghold|skills-stronghold-tech|' + - SINGLE_NATIVE_NEXUS_MODULE_PROFILES.join('|') + '|' + E3_NEXUS_MODULE_PROFILES.join('|') + '] ' + + MIXED_PAIRED_NEXUS_MODULE_PROFILES.join('|') + '|' + SINGLE_NATIVE_NEXUS_MODULE_PROFILES.join('|') + '|' + E3_NEXUS_MODULE_PROFILES.join('|') + '] ' + '[--rules both|basic|advanced] [--players all|2|3|4|5|6] [--max-actions POSITIVE] ' + '[--resume FAILED_GAME.json]\n' + + 'Mixed E1/E2 and paired E3 Nexus: mixed-nexus/mixed-discovery-nexus/mixed-homeworld-nexus/mixed-homeworld-nexus-discovery, or paired-e3-nexus/paired-e3-discovery-nexus/paired-e3-homeworld-nexus/paired-e3-homeworld-nexus-discovery, each optional -skills then -tech/-stronghold/-stronghold-tech. Both original E1/E2 native families with ix+choam union47, or BOTH Ecaz+Moritani/ecaz33 with classics; all12 Nexus and all14 only when Skills selected, Basic/Advanced2–6, Tech3+, Strongholds Advanced. Original native setup, Duke/assassination/capture/rounding/income/card guards remain; no public starts or save conversion. New17400–23700 bands leave older names/seeds unchanged.\n' + 'Single-native E1/E2: single-nexus and single-discovery-nexus, single-homeworld-nexus and single-homeworld-nexus-discovery, each with optional -skills and -tech/-stronghold/-stronghold-tech. Exactly one original Ixians OR Tleilaxu OR CHOAM OR Richese with classics, one family deck/all12 and all14 only when Skills selected. Basic/Advanced2–6, Tech3+, Strongholds Advanced; original setup/effects/ruling guards remain. Stable new14200–17300 bands; older names/seeds unchanged.\n' + 'Standalone E3 without Skills: e3-nexus, e3-discovery-nexus, e3-homeworld-nexus and e3-homeworld-nexus-discovery, each optionally -tech, -stronghold or -stronghold-tech. Original Ecaz OR Moritani/classic ecaz33/all12 setup, Basic/Advanced2–6, Tech3+, Strongholds Advanced, no training/public starts/save conversion. Native Duke/Occupy and Moritani traitor/Terror/assassination retain their original source and ruling boundaries. Stable new12600–14100 bands leave older names/seeds unchanged.\n' + 'Native trained Discovery/Nexus profiles: paired-discovery-nexus-skills[-tech|-stronghold|-stronghold-tech] retains original paired E1/E2 setup/all14; e3-discovery-nexus-skills[-tech|-stronghold|-stronghold-tech] retains standalone Ecaz OR Moritani original setup/all14. Discovery7/8/all12, Basic/Advanced2–6, Tech3+, Strongholds Advanced; original roster/custody/pending-rule limits and old defaults/seeds remain.\n' + @@ -762,7 +808,7 @@ function usage() { + '\nHomeworld-skills, homeworld-discovery and homeworld-discovery-skills use original classic/base33 rosters in Basic/Advanced2..6, all14 offered training when Skills is selected and Discovery7/8 when Discovery is selected. Each family may add -tech (original Tech3+), -stronghold (Advanced2+) or -stronghold-tech (Advanced3+). Original setup/cards/native force sources, physical rescue, Maker/entry, phase-end industry and source-local held effects remain. No Nexus, native families, public starts, save conversion or pending-rule changes.' + '\nNative-homeworld-skills, native-homeworld-discovery and native-homeworld-discovery-skills retain each existing E1/E2 or standalone Ecaz/Moritani roster/deck predicate and original Homeworld pools, Basic/Advanced2..6. Optional -tech requires3+; -stronghold requiresAdvanced2+; -stronghold-tech requiresAdvanced3+. Original private starts, all14 when selected, DS7/8 when selected, physical Suk/residual Cyborg exchange, native aftermath and held/industry/reward timing remain. Nexus, E3pairs/mixed families, unrelated previews, public starts, save conversion and pending rulings remain separate.' + '\nHomeworld-nexus-skills, homeworld-nexus-discovery and homeworld-nexus-discovery-skills compose original classic/base33/all12 with optional all14 training and Discovery7/8, Basic/Advanced2..6. Each family adds -tech at3+, -stronghold inAdvanced2+ or -stronghold-tech inAdvanced3+. Without Skills/Discovery, homeworld-nexus-tech, homeworld-nexus-stronghold and homeworld-nexus-stronghold-tech retain the same prerequisites. Original native Homeworld pools, real closing draws, printed revival deposits, physical Suk and phase-end industry/held rewards remain. Native families, unrelated previews, unresolved effects, public starts and save conversion stay guarded.' - + '\nPaired-homeworld-nexus-skills, paired-homeworld-nexus-discovery and paired-homeworld-nexus-discovery-skills retain original both-native E1 or E2 roster/deck contracts, Homeworlds/all12 and optional all14/DS7+8, Basic/Advanced2..6. Families add -tech at3+, -stronghold inAdvanced2+, -stronghold-tech inAdvanced3+. Paired-homeworld-nexus-tech/-stronghold/-stronghold-tech omit Skills/Discovery. E3-homeworld-nexus-skills and e3-homeworld-nexus-discovery-skills retain standalone Ecaz OR Moritani/classics, Ecaz33/all14/all12, with the same optional prerequisites and original Advanced Harkonnen/Duke/assassination guards. Original typed custody, real closing deals, physical rescue/substitution/marker invoices, phase-end industry and winner/held timing remain. Separate single-native E1/E2 and no-Skills E3 families above retain their own roster/effect predicates. Mixed decks, E3pairs/mixed families, pending effects, public starts and save conversion stay separate.' + + '\nPaired-homeworld-nexus-skills, paired-homeworld-nexus-discovery and paired-homeworld-nexus-discovery-skills retain original both-native E1 or E2 roster/deck contracts, Homeworlds/all12 and optional all14/DS7+8, Basic/Advanced2..6. Families add -tech at3+, -stronghold inAdvanced2+, -stronghold-tech inAdvanced3+. Paired-homeworld-nexus-tech/-stronghold/-stronghold-tech omit Skills/Discovery. E3-homeworld-nexus-skills and e3-homeworld-nexus-discovery-skills retain standalone Ecaz OR Moritani/classics, Ecaz33/all14/all12, with the same optional prerequisites and original Advanced Harkonnen/Duke/assassination guards. Original typed custody, real closing deals, physical rescue/substitution/marker invoices, industry and winner/held timing remain. Separate single-native E1/E2, mixed E1/E2, paired E3 and no-Skills standalone E3 families above retain their own exact roster/effect predicates. Three-family/other E3 mixtures, pending effects, public starts and save conversion stay separate.' ); } @@ -793,7 +839,7 @@ function positive(value: string | undefined) { } function scenarioName(scenario: Scenario) { - if (singleNativeNexusSampleProfile(scenario.profile) || e3NexusSampleProfile(scenario.profile) || homeworldModuleSampleProfile(scenario.profile)) + if (mixedPairedNexusSampleProfile(scenario.profile) || singleNativeNexusSampleProfile(scenario.profile) || e3NexusSampleProfile(scenario.profile) || homeworldModuleSampleProfile(scenario.profile)) return `${scenario.profile}-${scenario.expansions.join('-') || 'classic'}-${scenario.roster.join('-')}-${scenario.roster.length}-${scenario.rules}`; if (nativeDiscoverySkillsProfile(scenario.profile) || discoveryNexusSkillsProfile(scenario.profile) || pairedDiscoveryNexusSampleProfile(scenario.profile) || nativeDiscoveryNexusSkillsProfile(scenario.profile)) @@ -894,6 +940,8 @@ function freshGame(scenario: Scenario) { if (scenario.profile === 'paired-nexus-skills' || scenario.profile === 'nexus-skills' || scenario.profile === 'nexus' || nexusModuleProfile(scenario.profile) || scenario.profile === 'homeworld-nexus' || scenario.profile === 'choam-nexus' || scenario.profile === 'ecaz-nexus' || scenario.profile === 'ix-nexus' || scenario.profile === 'combined-nexus' || scenario.profile === 'combined-homeworld-nexus') game.nexusCards = { cards: null, phase: null }; if (homeworldDiscoverySampleProfile(scenario.profile) || nativeDiscoverySkillsProfile(scenario.profile) || discoverySkillsProfile(scenario.profile) || discoveryNexusProfile(scenario.profile)) game.discoveryEnabled = true; + if (pairedE3NexusSampleProfile(scenario.profile) && !skillProfile(scenario.profile)) + return initializePairedNexusGameForAudit(game); if (nativeHomeworldNexusSampleProfile(scenario.profile) && !homeworldSkillsSampleProfile(scenario.profile)) return initializePairedNexusGameForAudit(game); if (discoveryProfile(scenario.profile)) { @@ -963,11 +1011,11 @@ function resumedGame(path: string) { if ( (game.leaderSkills && !nativeExpansionLeaderSkillsProfile(game) && !composedSkills) || ((game.discoveryEnabled || game.discoveries || game.discoveryStash || game.greatMaker) && !discovery) || - (game.techTokens && !discovery && !composedSkills && !nativeFactionTechProfile(game) && !classicNexusModulesProfile(game) && !pairedNexusModulesProfile(game) && !singleE1E2NexusProfile(game) && !standaloneE3NexusProfile(game)) || + (game.techTokens && !discovery && !composedSkills && !nativeFactionTechProfile(game) && !classicNexusModulesProfile(game) && !pairedNexusModulesProfile(game) && !mixedE1E2NexusProfile(game) && !pairedE3NexusProfile(game) && !singleE1E2NexusProfile(game) && !standaloneE3NexusProfile(game)) || game.mentatQuestionPreview ) throw new Error('--resume sample scenarios exclude unsupported optional modules.'); - const scenario = [...SCENARIOS, ...SINGLE_NATIVE_NEXUS_MODULE_SCENARIOS, ...E3_NEXUS_MODULE_SCENARIOS, ...HOMEWORLD_NATIVE_NEXUS_MODULE_SCENARIOS, ...HOMEWORLD_CLASSIC_NEXUS_MODULE_SCENARIOS, ...COMBINED_NEXUS_SCENARIOS, ...COMBINED_HOMEWORLD_NEXUS_SCENARIOS, ...BASE_SCENARIOS, ...MODULE_SCENARIOS, ...EXPANSION_ROSTER_SCENARIOS, ...ECAZ_TREACHERY_SCENARIOS, ...ECAZ_OCCUPY_SCENARIOS, ...PAIRED_NEXUS_SCENARIOS, ...MORITANI_ASSASSINATE_SCENARIOS, ...STRONGHOLD_FACTIONS_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...IX_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS, ...RICHESE_SKILLS_SCENARIOS, ...ECAZ_SKILLS_SCENARIOS, ...SKILLS_TECH_SCENARIOS, ...SKILLS_STRONGHOLD_SCENARIOS, ...NATIVE_TECH_SCENARIOS, ...CLASSIC_NEXUS_MODULE_SCENARIOS, ...PAIRED_NEXUS_MODULE_SCENARIOS, ...NEXUS_SKILLS_SCENARIOS, ...PAIRED_NEXUS_SKILLS_SCENARIOS, ...NEXUS_SKILLS_MODULE_SCENARIOS, ...PAIRED_NEXUS_SKILLS_MODULE_SCENARIOS, ...E3_NEXUS_SKILLS_SCENARIOS, ...HOMEWORLD_OCCUPATION_SCENARIOS, ...DISCOVERY_SCENARIOS, ...DISCOVERY_SKILLS_SCENARIOS, ...DISCOVERY_NEXUS_SCENARIOS, ...DISCOVERY_STRONGHOLD_SCENARIOS, ...NATIVE_DISCOVERY_SKILLS_SCENARIOS, ...DISCOVERY_NEXUS_SKILLS_SCENARIOS, ...PAIRED_DISCOVERY_NEXUS_SCENARIOS, ...NATIVE_DISCOVERY_NEXUS_SKILLS_SCENARIOS, ...HOMEWORLD_CLASSIC_MODULE_SCENARIOS, ...HOMEWORLD_NATIVE_MODULE_SCENARIOS].find((candidate) => + const scenario = [...SCENARIOS, ...MIXED_PAIRED_NEXUS_MODULE_SCENARIOS, ...SINGLE_NATIVE_NEXUS_MODULE_SCENARIOS, ...E3_NEXUS_MODULE_SCENARIOS, ...HOMEWORLD_NATIVE_NEXUS_MODULE_SCENARIOS, ...HOMEWORLD_CLASSIC_NEXUS_MODULE_SCENARIOS, ...COMBINED_NEXUS_SCENARIOS, ...COMBINED_HOMEWORLD_NEXUS_SCENARIOS, ...BASE_SCENARIOS, ...MODULE_SCENARIOS, ...EXPANSION_ROSTER_SCENARIOS, ...ECAZ_TREACHERY_SCENARIOS, ...ECAZ_OCCUPY_SCENARIOS, ...PAIRED_NEXUS_SCENARIOS, ...MORITANI_ASSASSINATE_SCENARIOS, ...STRONGHOLD_FACTIONS_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...IX_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS, ...RICHESE_SKILLS_SCENARIOS, ...ECAZ_SKILLS_SCENARIOS, ...SKILLS_TECH_SCENARIOS, ...SKILLS_STRONGHOLD_SCENARIOS, ...NATIVE_TECH_SCENARIOS, ...CLASSIC_NEXUS_MODULE_SCENARIOS, ...PAIRED_NEXUS_MODULE_SCENARIOS, ...NEXUS_SKILLS_SCENARIOS, ...PAIRED_NEXUS_SKILLS_SCENARIOS, ...NEXUS_SKILLS_MODULE_SCENARIOS, ...PAIRED_NEXUS_SKILLS_MODULE_SCENARIOS, ...E3_NEXUS_SKILLS_SCENARIOS, ...HOMEWORLD_OCCUPATION_SCENARIOS, ...DISCOVERY_SCENARIOS, ...DISCOVERY_SKILLS_SCENARIOS, ...DISCOVERY_NEXUS_SCENARIOS, ...DISCOVERY_STRONGHOLD_SCENARIOS, ...NATIVE_DISCOVERY_SKILLS_SCENARIOS, ...DISCOVERY_NEXUS_SKILLS_SCENARIOS, ...PAIRED_DISCOVERY_NEXUS_SCENARIOS, ...NATIVE_DISCOVERY_NEXUS_SKILLS_SCENARIOS, ...HOMEWORLD_CLASSIC_MODULE_SCENARIOS, ...HOMEWORLD_NATIVE_MODULE_SCENARIOS].find((candidate) => (homeworldDiscoverySampleProfile(candidate.profile) || nativeDiscoverySkillsProfile(candidate.profile) || discoveryProfile(candidate.profile) || discoverySkillsProfile(candidate.profile) || discoveryNexusProfile(candidate.profile)) === discovery && (homeworldModuleSampleProfile(candidate.profile) || candidate.profile === 'homeworld' || candidate.profile === 'homeworld-occupation' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.homeworlds && (candidate.profile === 'paired-nexus-skills' || candidate.profile === 'nexus-skills' || candidate.profile === 'nexus' || nexusModuleProfile(candidate.profile) || candidate.profile === 'homeworld-nexus' || candidate.profile === 'choam-nexus' || candidate.profile === 'ecaz-nexus' || candidate.profile === 'ix-nexus' || candidate.profile === 'combined-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.nexusCards && @@ -977,7 +1025,7 @@ function resumedGame(path: string) { (homeworldStrongholdSampleProfile(candidate.profile) || discoveryStrongholdProfile(candidate.profile) || nativeStrongholdProfile(candidate.profile) || strongholdSkillProfile(candidate.profile) || nexusStrongholdProfile(candidate.profile)) === !!game.strongholdCards && (candidate.profile === 'ecaz-treachery' || candidate.ecazTreachery === true) === !!game.ecazTreachery && assassinationScenario(candidate) === !!game.moritaniAssassinatePreview && - (candidate.profile === 'ecaz-occupy' || ((e3NexusSampleProfile(candidate.profile) || homeworldModuleSampleProfile(candidate.profile) || nativeDiscoveryNexusSkillsProfile(candidate.profile) || nativeDiscoverySkillsProfile(candidate.profile) || nativeTechProfile(candidate.profile) || discoveryProfile(candidate.profile)) && candidate.roster.includes('ecaz'))) === !!game.ecazOccupyPreview && + (candidate.profile === 'ecaz-occupy' || ((pairedE3NexusSampleProfile(candidate.profile) || e3NexusSampleProfile(candidate.profile) || homeworldModuleSampleProfile(candidate.profile) || nativeDiscoveryNexusSkillsProfile(candidate.profile) || nativeDiscoverySkillsProfile(candidate.profile) || nativeTechProfile(candidate.profile) || discoveryProfile(candidate.profile)) && candidate.roster.includes('ecaz'))) === !!game.ecazOccupyPreview && assassinationScenario(candidate) === !!game.moritaniAssassinate && candidate.rules === (game.advanced ? 'advanced' : 'basic') && JSON.stringify(candidate.expansions) === @@ -1190,7 +1238,8 @@ async function main() { if ((discoveryTechProfile(profile) || (discoveryNexusProfile(profile) && nexusTechProfile(profile))) && players === 2) throw new Error('Discovery with Tech Tokens requires three through six players.'); const resume = values.resume ? resumedGame(values.resume) : null; - const requestedSamples = singleNativeNexusSampleProfile(profile) ? SINGLE_NATIVE_NEXUS_MODULE_SCENARIOS : + const requestedSamples = mixedPairedNexusSampleProfile(profile) ? MIXED_PAIRED_NEXUS_MODULE_SCENARIOS : + singleNativeNexusSampleProfile(profile) ? SINGLE_NATIVE_NEXUS_MODULE_SCENARIOS : e3NexusSampleProfile(profile) ? E3_NEXUS_MODULE_SCENARIOS : homeworldModuleSampleProfile(profile) ? (nativeHomeworldNexusSampleProfile(profile) ? HOMEWORLD_NATIVE_NEXUS_MODULE_SCENARIOS : homeworldNexusSampleProfile(profile) ? HOMEWORLD_CLASSIC_NEXUS_MODULE_SCENARIOS diff --git a/tools/prototype-room.ts b/tools/prototype-room.ts index f374206d..d0fea2d4 100644 --- a/tools/prototype-room.ts +++ b/tools/prototype-room.ts @@ -1,4 +1,4 @@ -import { pairedNexusModulesProfile } from '../game/nexus-module-profile'; +import { pairedE3NexusProfile, pairedNexusModulesProfile } from '../game/nexus-module-profile'; import type { DatabaseSync } from 'node:sqlite'; import { initializeIxGameForAudit, @@ -162,7 +162,7 @@ export function startPrototypeRoom( : profile === 'factions' ? initializeFactionExpansionsGameForAudit(initial) : profile === 'nexus' - ? pairedNexusModulesProfile(initial) + ? pairedNexusModulesProfile(initial) || pairedE3NexusProfile(initial) ? initializePairedNexusGameForAudit(initial) : initializeNexusGameForAudit(initial) : profile === 'leader-skills' diff --git a/tools/sample-custody.ts b/tools/sample-custody.ts index b78f1ea2..bb44a681 100644 --- a/tools/sample-custody.ts +++ b/tools/sample-custody.ts @@ -158,6 +158,7 @@ export function verifySampleCustody( assert.deepEqual( [ ...(game.traitorReserve ?? []), + ...(game.ecazLoyalty?.card ? [game.ecazLoyalty.card] : []), ...game.players.flatMap((player) => [ ...player.traitors, ...(player.faceDancers ?? []).map(card => card.leader), ]), diff --git a/tools/start-prototype.ts b/tools/start-prototype.ts index 939f9024..38428905 100644 --- a/tools/start-prototype.ts +++ b/tools/start-prototype.ts @@ -36,7 +36,8 @@ async function main() { '\nNexus additionally routes a supported paired native E1 (Ixians+Tleilaxu) OR E2 (CHOAM+Richese) lobby with classics and one required deck through original paired setup when Tech and/or Advanced Strongholds is selected. Tech requires3–6 Basic/Advanced, Strongholds2–6 Advanced, both3–6 Advanced. All12 Nexus, original47/35 decks/cache, native setup and actual module custody remain. Mixed families, E3, Skills, other overlays, public starts and pending rulings stay separate.', '\nNexus additionally admits standalone Ecaz OR Moritani with classics and the original ecaz33/all12 deck without Skills, Basic/Advanced2–6, optionally original Homeworlds, Discovery through --discoveries, Tech3+ and Advanced Strongholds2+. Original Traitors/native setup and qualifying end-Spice closing draw remain; no starting Nexus deal or training is added. Ecaz Arrakis Occupy and quiet living-Duke Cunning retain their own boundaries; an otherwise legal Advanced Ecaz/Harkonnen roster does not lift the separate Cunning exclusion. Advanced Moritani excludes Harkonnen and uses original post-loss assassination/normal-call forfeiture, private replacement and supply-only Terror Cunning. E3 pairs/mixed families, native free-return/relocation rulings, unrelated previews, public starts and save conversion stay separate.', '\nNexus additionally admits exactly one native Ixians OR Tleilaxu with the Ix47 deck, or CHOAM OR Richese with CHOAM35 and the original Richese cache when seated, plus classic opponents, Basic/Advanced2–6. Original Homeworlds may be retained; --discoveries preserves Discovery7/8, Tech requires3+ and Strongholds require Advanced2+. Without Skills it reuses original faction/Traitor/native setup; leader-skills --nexus-cards additionally retains all14 offered training. Original Ix offer/HMS, Tleilaxu Face Dancers, CHOAM Auditor and signed Richese markers remain physical. The paired entry still requires both native factions. Mixed-family Nexus, E3pairs, public starts, played-save conversion and pending effect rulings remain separate.', - '\nLeader-skills --nexus-cards explicitly adds all12 Nexus to fresh classic/base33 OR single or paired Ixians/Tleilaxu with Ix47 OR single or paired CHOAM/Richese with CHOAM35 and native Richese cache, plus classics and all14 skills, two-through-six seats Basic/Advanced. Existing Homeworlds may be retained. Original starting hands/offers, skill/Traitor/native order and genuine closing deals remain. Mixed families/E3 pairs, Banker/Mentat previews, borrowed Smuggler/pair-companion arithmetic, public starts and unresolved interactions stay separate.', + '\nThe original Nexus and Skills/Nexus entries additionally admit natives from BOTH selected Ix+CHOAM families with classics and union47 Treachery Cards, or BOTH Ecaz+Moritani with classics/ecaz33, Basic/Advanced2–6. Optional Homeworlds/Discovery7+8/Tech3+/Advanced Strongholds2+ retain original setup and consumers. Richese cache stays separate; paired E3 preserves ordinary native training, untrained shared Duke, Arrakis Occupy and Advanced assassination, excluding Advanced Harkonnen. Existing mixed-family free-return/companion, own concealed-plan, Sandtrout, low-income/cache-count, shared/captured training and Terror relocation questions remain guarded. No public starts or played-save conversion.', + '\nLeader-skills --nexus-cards explicitly adds all12 Nexus to fresh classic/base33, original single or paired E1/E2 with one family deck, both selected Ix+CHOAM native families with union47, or standalone/paired Ecaz/Moritani with ecaz33 and classics, all14 skills, two-through-six seats Basic/Advanced. Existing Homeworlds may be retained. Original starting hands/offers, skill/Traitor/native order and genuine closing deals remain. Each native roster/capture guard, Banker/Mentat previews, borrowed Smuggler/pair-companion arithmetic, public starts and unresolved interactions retain their boundaries.', '\nThese classic, single-native or paired one-family skill-Nexus entries preserve selected unused original Tech Tokens at3–6 Basic/Advanced and/or Stronghold Cards at2–6 Advanced; both require3–6 Advanced. Real first-Storm/end-Mentat custody, phase-end industry, held subsidy, typed skill rescue/equal substitution, signed one-invoice marker pairs and mandatory original-winner Tech before Face Dance use original handlers. No mixed-family/native free-return accounting or pending rulings are opened.', '\nThe same fresh skill/Nexus entry admits standalone Ecaz OR Moritani with classics, exact ecaz33/all14/all12, Basic/Advanced2–6. Optional unused Tech requires3+; Strongholds requires Advanced; both3+ Advanced. Native Ecaz five-disc/Duke assignment and quiet living-Duke Cunning, plus original Advanced Moritani skill-first assassination/normal-traitor forfeiture, remain. Advanced Harkonnen, shared-Duke assignment, enhanced Terror relocation/HMS/Grumman and other pending effects stay guarded.', '\nIn the standalone Ecaz skill/Nexus profile, mandatory Advanced Occupy composes the chosen faction’s skill with separately labelled armies. Ecaz-led Suk rescues its own fixed casualties after the ally’s variable losses; Diplomat uses the chosen faction’s undialed own pool. Shared training, Basic odd rounding and other pending effects remain separate.', @@ -44,9 +45,9 @@ async function main() { '\nWhen native CHOAM is seated, this fresh entry additionally marks original occupied Tupile hand slots and owner-selected normal-limit cleanup. The occupier/reciprocal ally gain one validated slot through existing capacity consumers; Advanced departure and ally removal queue the original lost-holder cleanup. Private selected-ID discards preserve current phase/opening and remaining movement, with no new card or fee. Known occupation suppresses new native low-intelligence queries; proved Advanced expiry restores eligibility without resetting historical answers/usage. Basic unknown cases retain source guards. Unmarked played profiles are not upgraded.', '\nWhen native Ecaz is seated, this original fresh entry separately marks its printed high Homeworld alliance victory: current native seven-plus, one actual joint stronghold, two distinct other native factions with proved current occupied holdings. It adds no ordinary stronghold/Tech points, pays no reward and changes no Duke authority. Public progress remains prospective until original Mentat; qualifying members join original winners before BG prediction/final fallback. Unmarked played/public profiles remain unchanged.', '\nDiscovery opts fresh classic, selected E1/E2 native families or standalone Ecaz OR Moritani with classics into original Discovery setup in Basic/Advanced2–6, optionally retaining original Homeworlds, unused Tech3–6 and Advanced Strongholds2–6. Original decks/cache, HMS/Face Dancers, typed entry, revealed-only nested No-Fields, stash limits, end-Mentat claims and native outcomes remain. Advanced Moritani excludes Harkonnen. Skills/Nexus, paired/mixed E3, contested/shared-payer rulings, public starts and played-game conversion stay separate.', - '\nLeader-skills --discoveries composes fresh supported classic or native original family decks/all14 Skills with Discovery7/8 at2–6 seats Basic/Advanced under each existing roster predicate. Original Homeworlds may be retained; adding --nexus-cards requires classic factions, single or paired native Ixians/Tleilaxu OR CHOAM/Richese with one family deck, or standalone Ecaz OR Moritani with classics. Tech requires3–6 and Strongholds Advanced2–6. Original private card choices/training/hands, Maker/votes/rides, physical residual Cyborg exchange, native aftermath and cleanup/Tech/Face Dance retain their own timing. Mixed decks, E3pairs/mixed families, independent variants, Banker/Mentat previews, shared Duke/borrowed Smuggler/pair companion/native returns and pending rulings remain separate.', - '\nNexus --discoveries explicitly composes fresh classic/base33, single or paired native Ixians/Tleilaxu with Ix47 OR CHOAM/Richese with CHOAM35 and native Richese cache, or standalone Ecaz OR Moritani/ecaz33 with classics, all12 Nexus and original Discovery7/8 at2–6 seats Basic/Advanced without Skills, optionally retaining original Homeworlds. Unused Tech requires3+; optional Strongholds require Advanced; both3+. Actual Great Maker losses/votes/typed reserve rides, both Advanced piles, settled-alliance end-Spice closing deals and original held claims remain. Signed native two-marker nested shipments retain one printed invoice, current revealed destination and original reserve materialization. E3pairs/mixed families, mixed-module Fremen Betrayal, pending rulings, public activation and save conversion stay guarded.', - '\nFresh supported classic or native E1/E2/standalone E3 lobbies with original Homeworlds support leader-skills, discovery or leader-skills --discoveries, Basic/Advanced2–6. Preserve unused Tech3+ and Advanced Strongholds2+; both requireAdvanced3+. Classic, single-native or paired E1/E2, or standalone Ecaz OR Moritani/classic entries additionally compose Nexus through original leader-skills --nexus-cards with optional --discoveries, or original nexus with optional --discoveries/Tech/Strongholds without Skills. Native roster/deck/assassination/Duke boundaries remain. Original all14 when Skills selected/DS7+8 when selected, private native starts, split Imperial and typed native/visitor/nested pools, real closing draws, printed native revival deposits, phase-end industry, held effects and winner rewards use original consumers. Mixed-deck Nexus, unsupported E3pair/mixed rosters, unrelated previews, public starts and save conversion remain separate.', + '\nLeader-skills --discoveries composes fresh supported classic or native original decks/all14 Skills with Discovery7/8 at2–6 seats Basic/Advanced under each roster predicate. Original Homeworlds may be retained; adding --nexus-cards supports original single/paired one-family E1/E2, natives from both selected Ix+CHOAM families, and standalone/paired Ecaz/Moritani with classics. Tech requires3–6 and Strongholds Advanced2–6. Original private offers/training/hands, Maker/votes/rides, residual Cyborg exchange, native aftermath and cleanup/Tech/Face Dance retain their own timing. Three-family mixtures, independent variants, Banker/Mentat previews, shared/captured Duke training, borrowed Smuggler/pair companion/native returns and pending rulings remain separate.', + '\nNexus --discoveries explicitly composes fresh classic/base33, original single/paired E1/E2 one-family decks, natives from both selected Ix+CHOAM families/union47, or standalone/paired Ecaz/Moritani/ecaz33 with classics, all12 Nexus and Discovery7/8 at2–6 seats Basic/Advanced without Skills, optionally Homeworlds. Unused Tech requires3+; Strongholds requires Advanced; both3+. Actual Maker losses/votes/typed reserve rides, both Advanced piles, settled-alliance end-Spice deals and held claims remain. Native signed two-marker shipments retain one invoice, revealed destination and original reserve materialization. Mixed E3 families, mixed-module Fremen Betrayal, pending effects, public activation and save conversion stay guarded.', + '\nFresh supported classic or native lobbies with original Homeworlds retain leader-skills, discovery or both under their original contracts. The Nexus entries additionally retain source-clear single/paired E1/E2, mixed selected Ix+CHOAM natives and standalone/paired Ecaz/Moritani, Basic/Advanced2–6, optional all14 Skills/Discovery7+8/Tech3+/Advanced Strongholds2+. Original native decks/inventories/capture exclusions remain; shared Duke never becomes a training disc. Actual typed native/visitor/nested pools, real closing draws, original revival deposits, industry, held effects and winner rewards use the existing consumers. Three-family/other E3 mixtures, unrelated previews, pending effects, public starts and played-save conversion remain separate.', ); return; }