From 2270c07f9722660f3dfe1d90626f7ecc81537a73 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:13:07 +1100 Subject: [PATCH] Compose native trained Skills Nexus Discovery modules --- docs/CURRENT_STATUS.md | 26 + docs/DISCOVERY_PROTOTYPE.md | 54 ++ docs/IMPLEMENTATION_STATUS.md | 31 ++ docs/LEADER_SKILLS_RUNTIME.md | 27 + docs/NEXUS_CARD_RULES.md | 31 ++ docs/RULE_DECISIONS.md | 3 +- game/engine.ts | 3 +- game/leader-skill-profile.ts | 8 +- game/reference.ts | 4 +- tests/discovery-module-profile.test.ts | 2 +- ...ery-paired-nexus-e1-skills-runtime.test.ts | 356 +++++++++++++ ...ery-paired-nexus-e2-skills-runtime.test.ts | 384 ++++++++++++++ ...standalone-nexus-e3-skills-runtime.test.ts | 329 ++++++++++++ ...ixture-discovery-paired-nexus-e1-skills.ts | 362 +++++++++++++ ...ixture-discovery-paired-nexus-e2-skills.ts | 375 +++++++++++++ ...re-discovery-standalone-nexus-e3-skills.ts | 492 ++++++++++++++++++ tools/faction-games.ts | 39 +- tools/start-prototype.ts | 2 +- 18 files changed, 2510 insertions(+), 18 deletions(-) create mode 100644 tests/discovery-paired-nexus-e1-skills-runtime.test.ts create mode 100644 tests/discovery-paired-nexus-e2-skills-runtime.test.ts create mode 100644 tests/discovery-standalone-nexus-e3-skills-runtime.test.ts create mode 100644 tests/fixture-discovery-paired-nexus-e1-skills.ts create mode 100644 tests/fixture-discovery-paired-nexus-e2-skills.ts create mode 100644 tests/fixture-discovery-standalone-nexus-e3-skills.ts diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 1de8adff..4be2c3e5 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,6 +17,32 @@ open mode gates. ## Current checkpoint and work +**Native trained Skills/Nexus/Discovery — bounded Development Verified:** +original paired E1 Ixians+Tleilaxu/Ix47, paired E2 CHOAM+Richese/CHOAM35+cache +and standalone Ecaz **or** Moritani with classics now compose all14 original +offered training, all12 Nexus and Discovery7/8 in Basic/Advanced2–6, with +optional unused Tech3+ and Advanced Strongholds2+. Only the existing +paired/E3 skill-family predicates gained the Discovery envelope; original +setup, first hands, offers, actors and native components remain authoritative. +Mixed/E3 pairs, shared-Duke training, borrowed Smuggler/pair-companion +arithmetic, native free-return, captured/foreign training, Basic odd Ecaz, +Advanced Harkonnen and the exhausted-cache count ruling stay guarded. + +Affected205/14files, types/lint and build pass. All14 real six-seat profiles +were exercised: **23 of24** original samples completed with **25,751 accepted/ +25,751 attempts/685 JSON**, including actual typed Cunning rescue before equal +Cyborg substitution, physical winner cleanup/Tech before matching Face Dance, +one-invoice signed E2 nested pairs, mandatory owner-labelled Ecaz Occupy and +original Moritani assassination/own revival. The single unfinished sample +(Richese seed20273010) is preserved at turn-10 Bidding with an empty cache and +the existing `richese-settlement` exhausted-cache count guard:1,726accepted/ +1,727attempts/46JSON, no bypass. Original human rooms BWHS4DU6/GRM4XMUH/ +5JS3SUYY/VFQCEVFB retain actual offered training (Rihani/Prana/Suk/Smuggler, +Master of Assassins, Killer Medic/Sandmaster) and free entry, with Duke Vidal +and the Moritani Atomics/assassination state live. Local working-tree proof, +not deployed acceptance. +[Canonical combined evidence](DISCOVERY_PROTOTYPE.md#native-trained-skillsnexusdiscovery--6-october-2026). + **Classic Skills/Nexus/Discovery and paired native Nexus/Discovery — bounded Development Verified:** original fresh classic base33/all14/all12 training and separate paired E1/E2 no-Skills47/35/cache setup retain Discovery7/8, diff --git a/docs/DISCOVERY_PROTOTYPE.md b/docs/DISCOVERY_PROTOTYPE.md index 25c94a83..c3ce122a 100644 --- a/docs/DISCOVERY_PROTOTYPE.md +++ b/docs/DISCOVERY_PROTOTYPE.md @@ -35,6 +35,60 @@ node --import tsx tools/start-prototype.ts --profile discovery --db PATH --room It preserves all seats and other rooms, requires the current version and refuses to redeal a started game. The default `ix` profile remains available. +## Native trained Skills/Nexus/Discovery — 6 October 2026 + +Fresh original `leader-skills --nexus-cards --discoveries` now composes +Discovery7/8 with each already-supported native skill/Nexus family: +both Ixians+Tleilaxu/Ix47, both CHOAM+Richese/CHOAM35+cache, or standalone +Ecaz **or** Moritani with classics. Basic/Advanced2–6, optional unused Tech3+ +and Advanced Strongholds2+; original actors, first hands, offers, native setup +and printed components stay authoritative. Mixed/E3 pairs, Homeworlds, +variants/previews, shared-Duke training, borrowed Smuggler/pair-companion +arithmetic, native free-return, captured/foreign training, Basic odd Ecaz, +Advanced Harkonnen, the exhausted-cache count ruling, public starts and +played-save conversion remain guarded. + +Only the existing `pairedNexusLeaderSkillsProfile` and +`standaloneE3NexusLeaderSkillsProfile` envelopes gained the existing private +Discovery flag; `nativeExpansionLeaderSkillsProfile`/`nativeDiscoveryLeaderSkillsProfile` +and the original `initializeLeaderSkillsGameForAudit` already route them, so +no new setup path or duplicated legality was added. `richeseCunningModeSupported` +admits selected Discovery only through the exact supported paired-skill profile. +The original Ecaz mandatory coalition and Moritani assassination/own-revival +consumers are inherited unchanged. + +The programme reaches the actual next-turn free entry after the first END +Mentat, then real Great Maker losses/votes/typed Fremen reserve ride, both +Advanced piles, settled alliance and the end-Spice closing choice. Trained +consumers remain physical: E1 six-counter Suboid Cunning resolves source-clear +Suk rescue before one equal Cyborg Tank substitution, then original cleanup and +mandatory winner Tech precede a matching Face Dance that kills the trainer and +returns its skill once. E2 keeps the signed two-marker frame, one printed +invoice, private reserve caps and native response/Guild continuation. Standalone +Ecaz keeps the quiet living/unclaimed Duke separate from training and a real +owner-labelled mandatory Occupy; Moritani keeps skill-first assassination, +printed bounty and one private Mentat replacement. + +Affected union **205/205** across14 files; types/lint and build pass. All14 real +six-seat profiles were exercised. **23 of24** original samples completed with +**25,751 accepted/25,751 attempts/685 JSON round trips**, unchanged working tree +`b1177766e25ad2b8818238fb04bc425d6f1b690d0e82b948d7db608718a9d23a`. The one +unfinished sample (Richese seed20273010) is preserved at turn-10 Bidding with an +empty cache under the existing `richese-settlement` exhausted-cache count guard +(1,726accepted/1,727attempts/46JSON); no bypass or re-run was applied. New +5600–6300 sample bands use full roster/expansion names, so earlier profiles, +defaults and seeds are unchanged. + +Four new isolated human rooms preserve their original CLI setups. +BWHS4DU6 (Ixians) retains offered Rihani Decipherer/Prana Bindu Adept/Suk +Graduate/Smuggler; human free Shrine2ordinary+1Cyborg and the Hidden Mobile +Stronghold control remain. GRM4XMUH (Richese) retains Master of Assassins/ +Suk Graduate/Mentat/Warmaster with actual auction acquisitions. 5JS3SUYY (Ecaz) +retains Killer Medic/Smuggler/Bureaucrat, Duke Prad Vidal and Cistern3. +VFQCEVFB (Moritani) retains Sandmaster/Killer Medic/Master of Assassins with +Duke Prad Vidal and the live Atomics/assassination state. These are local +working-tree controls, not protected live acceptance. + ## Classic Skills/Nexus and paired native Nexus Discovery — 6 October 2026 Fresh classic `leader-skills --nexus-cards --discoveries` composes base33, diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 90d505ab..d4c8fb81 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,36 @@ # Dune implementation status +## 6 October 2026 — Native trained Skills/Nexus/Discovery + +Only the existing paired E1/E2 and standalone E3 skill/Nexus family predicates +gained the private Discovery envelope; `nativeExpansion`/`nativeDiscovery` +and the original `initializeLeaderSkillsGameForAudit` already route them, so +no duplicated setup/legality was added. `richeseCunningModeSupported` admits +selected Discovery only through the exact supported paired-skill profile. +Original actors/hands/offers/native components and the mandatory Ecaz coalition +plus Moritani assassination/own-revival consumers are inherited unchanged. + +Affected205/14files, types/lint and build pass. All14 real six-seat profiles +were exercised:23 of24 original samples complete25,751accepted/25,751attempts/ +685JSON on unchangedb1177766 working tree. Trained consumers are physical: +E1 Suk rescue before one equal Cyborg Tank substitution then cleanup/Tech/ +matching Face Dance; E2 signed one-invoice nested pair with private caps; +Ecaz quiet living/unclaimed Duke then owner-labelled mandatory Occupy; +Moritani skill-first assassination/printed bounty/one private replacement. +One Richese sample (seed20273010) is preserved at turn-10 Bidding with an +empty cache under the existing `richese-settlement` exhausted-cache count +guard (1,726accepted/1,727attempts/46JSON); no bypass. + +Four new isolated human rooms preserve original CLI setups and offered training +(BWHS4DU6 Rihani/Prana/Suk/Smuggler; GRM4XMUH Master of Assassins/Suk/Mentat/ +Warmaster; 5JS3SUYY Killer Medic/Smuggler/Bureaucrat+Duke Vidal; VFQCEVFB +Sandmaster/Killer Medic/Master of Assassins+Duke Vidal+Atomics). New5600–6300 +sample bands use full roster/expansion names, leaving earlier profiles/defaults/ +seeds unchanged. Fixture repairs preserve real progression: actual auction +purchases, forced-loss-to-Suk ordering, valid native setup flags, coalition +targets and trained-leader posture. No protected deployment or complete-mode +acceptance is claimed. + ## 6 October 2026 — Classic Skills/Nexus and paired native Nexus Discovery Fresh original classic base33/all14/all12 and separate paired E1/E2 no-Skills diff --git a/docs/LEADER_SKILLS_RUNTIME.md b/docs/LEADER_SKILLS_RUNTIME.md index 8baf79bd..2643b0e9 100644 --- a/docs/LEADER_SKILLS_RUNTIME.md +++ b/docs/LEADER_SKILLS_RUNTIME.md @@ -5,6 +5,33 @@ 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. +## Native trained Skills/Nexus/Discovery — 6 October 2026 + +Fresh original `leader-skills --nexus-cards --discoveries` now extends the +existing paired E1/E2 and standalone Ecaz-or-Moritani skill/Nexus families to +the original Discovery envelope: all14 offered training, all12 Nexus and +Discovery7/8, Basic/Advanced2–6, optional unused Tech3+ and Advanced +Strongholds2+. Only those two existing profile predicates gained the Discovery +flag; `nativeExpansion`/`nativeDiscoveryLeaderSkillsProfile` and the original +initializer already route them, so setup, first hands, offers, actors and +native components stay authoritative. Mixed/E3 pairs, Homeworlds, variants/ +previews, shared-Duke training, borrowed Smuggler/pair companions, native +free-return, captured/foreign training, Basic odd Ecaz, Advanced Harkonnen, +the exhausted-cache count ruling, public starts and save conversion stay +separate. + +Actual Maker losses/votes/typed rides, both Advanced piles and settled alliance +precede the end-Spice closing draw. Physical trained consumers remain: E1 Suk +rescue before equal Cyborg substitution then cleanup/Tech/Face Dance, E2 signed +one-invoice nested pair, Ecaz quiet Duke then owner-labelled mandatory Occupy, +and Moritani skill-first assassination/own revival. + +[Canonical combined evidence](DISCOVERY_PROTOTYPE.md#native-trained-skillsnexusdiscovery--6-october-2026) +records the205/14 affected union,23 of24 completed original six-seat samples +(25,751accepted/685JSON) and the preserved Richese exhausted-cache capture. +This bounded composition does not certify complete factions or all fourteen +effects, and is not protected deployment acceptance. + ## Classic Nexus/Discovery composition — 6 October 2026 The fresh original classic `leader-skills --nexus-cards --discoveries` entry diff --git a/docs/NEXUS_CARD_RULES.md b/docs/NEXUS_CARD_RULES.md index 9a273276..95959c1b 100644 --- a/docs/NEXUS_CARD_RULES.md +++ b/docs/NEXUS_CARD_RULES.md @@ -2,6 +2,37 @@ 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. +## Native trained Skills/Nexus/Discovery — 6 October 2026 + +The original fresh `leader-skills --nexus-cards --discoveries` entry now +composes Discovery7/8 with each already-supported native skill/Nexus family: +both Ixians+Tleilaxu/Ix47, both CHOAM+Richese/CHOAM35+cache, or standalone +Ecaz **or** Moritani with classics. Basic/Advanced2–6, optional unused Tech3+ +and Advanced Strongholds2+; original actors, first hands, offers, native setup +and printed components remain authoritative. Only the existing +`pairedNexusLeaderSkillsProfile`/`standaloneE3NexusLeaderSkillsProfile` +envelopes gained the private Discovery flag, so no new setup path or duplicated +legality was added. `richeseCunningModeSupported` admits selected Discovery +only through that exact paired-skill profile. + +The programme reaches actual next-turn free entry after the first END Mentat, +then real Maker losses/votes/typed rides, both Advanced piles, settled alliance +and the end-Spice closing choice. Physical consumers remain: E1 six-counter +Suboid Cunning resolves source-clear Suk rescue before one equal Cyborg Tank +substitution, then original cleanup and mandatory winner Tech precede a +matching Face Dance; E2 keeps the signed two-marker frame, one printed invoice +and private reserve caps; Ecaz keeps quiet living/unclaimed Duke separate from +training and a real owner-labelled mandatory Occupy; Moritani keeps skill-first +assassination, printed bounty and one private Mentat replacement. + +[Combined canonical contract/proof](DISCOVERY_PROTOTYPE.md#native-trained-skillsnexusdiscovery--6-october-2026) +records the205/14 affected union, types/lint/build, **23 of24** completed +original six-seat samples (25,751accepted/685JSON) and the preserved Richese +exhausted-cache capture. Mixed/E3 pairs, Homeworlds, variants/previews, +shared-Duke training, borrowed Smuggler/pair companions, native free-return, +captured/foreign training, Basic odd Ecaz, Advanced Harkonnen and unresolved +rulings remain gated; this is not complete-mode or deployed acceptance. + ## Original Discovery, classic Skills and paired natives — 6 October 2026 The existing original fresh classic skill/Nexus entry additionally accepts diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 0ae41a67..de1a8bb1 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -235,7 +235,8 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP | Classic Nexus / Skills / Tech / Strongholds | Fresh classic original skill/Nexus entry retains selected unused Tech3..6 Basic/Advanced and/or Strongholds2..6 Advanced. Original setup/closing deals/owner timing, distinct free returns and phase-end industry remain. Held subsidy changes actual payer debit, not committed support/Suk entitlement; physical rescue precedes card cleanup and mandatory winner token choice. Original once-per-phase Bureaucrat diversion composes player payments. Captured-training leader revival remains blocked; public Ghola/ordinary options now omit those illegal choices rather than strand legal AI passes. Paired native combined components, HW, other previews, public/save conversion and new rulings stay guarded. | [Combined source contract](NEXUS_CARD_RULES.md#classic-nexus-skills-and-original-techstrongholds--4-october-2026) | | Paired Nexus / Skills / Tech / Strongholds | Both Ixians+Tleilaxu OR CHOAM+Richese plus classics and one original family deck/all14/all12 preserve selected unused Tech3..6 Basic/Advanced and/or Strongholds2..6 Advanced through the original fresh skill/Nexus entry. Typed rescue precedes equal actual Cyborg Tank substitution, original card cleanup/mandatory winner Tech precedes Face Dance; held subsidy changes debit, not committed support, and reward/custody stay original winner. Signed marker pair/one invoice/native industry retains original timing. Bounded runtime/program/phone/genuine-game evidence does not certify full modules; mixed families/E3/HW/previews/companions/native Nexus free-return rulings/public start/save conversion stay guarded. | [Paired combined contract and evidence](NEXUS_CARD_RULES.md#paired-nexus-skills-and-original-techstrongholds--5-october-2026) | | Classic Nexus / Leader Skills | Fresh classic/base33/all14 Skills/all12 Nexus, Basic/Advanced2..6, explicit `leader-skills --nexus-cards`, optionally original Tech3+/Advanced Strongholds2+ and Discovery7/8 through `--discoveries`; HW/variants/unrelated previews stay separate. Original starting hands/offers/skill-before-Traitor setup and actual closing Nexus draw remain. Exact extra/ordinary free-return ledgers, real bank purchase and committed Traitor declarations preserve trained-disc custody. Temporary ordinary-as-Sardaukar roles compose actual skill posture/bonuses/support; matching Cunning casualty receipt stays pending through original Suk rescue and completes only after physical settlement. Source-guarded borrowed Smuggler arithmetic, Atreides Suk/KH and unresolved effects remain; no public start, retrofit or new ruling. | [Scope, sources and bounded evidence](NEXUS_CARD_RULES.md#original-discovery-classic-skills-and-paired-natives--6-october-2026), [Skill runtime](LEADER_SKILLS_RUNTIME.md#classic-nexusdiscovery-composition--6-october-2026) | -| Paired E1/E2 Nexus / Leader Skills | Fresh both Ixians+Tleilaxu OR CHOAM+Richese plus classics, one original family deck/all14 Skills/all12 Nexus, Basic/Advanced2..6, explicit original skill entry. Actual native setup/offers/closing draws remain. Full unsupported Suboids compose typed Suk rescue before equal actual Tank substitution; Face Dance follows original winner outcomes, kills/returns the winning trainer skill once without another bounty, and partial Cunning retains ordinary Mentat rights. Richese pair uses its original signed two-token frame/one invoice/native response/Guild continuation, not single-marker skill proof; CHOAM printed fuel/Planetologist physical route remain separate. No other overlays, companion/mixed-plan/new ruling/public starts or retrofit. | [Scope, sources and bounded evidence](NEXUS_CARD_RULES.md#paired-e1e2-nexus-and-leader-skills-composition--4-october-2026), [Skill runtime](LEADER_SKILLS_RUNTIME.md#paired-e1e2-nexus-composition--4-october-2026) | +| Paired E1/E2 Nexus / Leader Skills | Fresh both Ixians+Tleilaxu OR CHOAM+Richese plus classics, one original family deck/all14 Skills/all12 Nexus, Basic/Advanced2..6, explicit original skill entry; optional original Discovery7/8 through the same private envelope. Actual native setup/offers/closing draws remain. Full unsupported Suboids compose typed Suk rescue before equal actual Tank substitution; Face Dance follows original winner outcomes, kills/returns the winning trainer skill once without another bounty, and partial Cunning retains ordinary Mentat rights. Richese pair uses its original signed two-token frame/one invoice/native response/Guild continuation, not single-marker skill proof; CHOAM printed fuel/Planetologist physical route remain separate. No other overlays, companion/mixed-plan/new ruling/public starts or retrofit. | [Scope, sources and bounded evidence](NEXUS_CARD_RULES.md#native-trained-skillsnexusdiscovery--6-october-2026), [Skill runtime](LEADER_SKILLS_RUNTIME.md#native-trained-skillsnexusdiscovery--6-october-2026) | +| Standalone E3 Nexus / Leader Skills / Discovery | Fresh one native Ecaz OR Moritani plus classics, exact ecaz33/all14/all12/Discovery7/8, Basic/Advanced2–6, optional Tech3+/Advanced Strongholds2+ through the same original skill entry. Real quiet living/unclaimed Duke Cunning stays separate from training; later Nexus alliance opens a genuine owner-labelled mandatory Occupy with physical payments/cleanup/Tech. Moritani keeps Advanced skill-first assassination/printed bounty/one private Mentat replacement and Basic without that power. Advanced Harkonnen, shared Duke, paired/mixed E3, enhanced Terror/HMS/Grumman and other pending effects stay guarded. | [Scope and bounded evidence](NEXUS_CARD_RULES.md#native-trained-skillsnexusdiscovery--6-october-2026), [Skill runtime](LEADER_SKILLS_RUNTIME.md#native-trained-skillsnexusdiscovery--6-october-2026) | | Nexus / Discovery / original components | Fresh classic/base33/all12/Discovery7/8 Basic/Advanced2–6 optionally retains all14 original Skills through `leader-skills --nexus-cards --discoveries`; separate `nexus --discoveries` additionally admits both native E1 Ixians+Tleilaxu/Ix47 OR E2 CHOAM+Richese/CHOAM35+cache without Skills. Optional unused Tech3+/Advanced Strongholds2+ retain original setup/claims. Actual Maker losses/votes/typed rides, both Advanced piles and settled alliance precede the end-Spice closing draw. Source-clear return ledgers, trained battle and signed two-marker one-invoice/current-revealed-site native materialization use original handlers. Native combined Skills/Nexus/Discovery, E3/mixed families, HW/variants/previews and unresolved mixed-module Fremen Betrayal remain gated; no new universal interruption priority or ruling. | [Combined contract and bounded evidence](DISCOVERY_PROTOTYPE.md#classic-skillsnexus-and-paired-native-nexus-discovery--6-october-2026), [Nexus source contract](NEXUS_CARD_RULES.md#classic-discovery--original-tech-and-strongholds--5-october-2026) | | Discovery / original Advanced Strongholds | Fresh original Discovery classic/selected E1/E2/standalone E3 and separate classic all14 Skills entry retain Strongholds6 Advanced2–6, optional Tech3–6 and original end-Mentat claims. Named held benefits or actual HMS copy do not inherit into nested locations; Jacurutu creates no seventh card. Live supported Ecaz Discovery feeds mandatory original coalition lead/armies/payment/aftermath rather than skipping legal battle. Native typed/marker, trained rescue and winner Tech retain their sources. Basic Strongholds, native Discovery Skills, HW/variants/previews/pending rules/public starts/save conversion remain separate. | [Bounded contract and original programmes](DISCOVERY_PROTOTYPE.md#original-advanced-stronghold-composition--5-october-2026), [mandatory Ecaz repair](ECAZ_OCCUPY_RULES.md#original-live-discovery-coalition--5-october-2026) | | Native Leader Skills / Discovery | Original supported native Basic/Advanced family/roster predicates retain selected native decks/hands/all14 and Discovery7/8, optional Tech3+/Advanced Strongholds2+. Typed rescue precedes actual Cyborg substitution; original cleanup/Tech precedes Face Dance. Native stash/invoice/green marker and trained assassination/own revival keep physical sources. Actual starting hands/offers are authoritative. Hidden-Smuggler legal participation uses the engine's projected support quote, not private-state profile reconstruction. Native Ecaz owner-labelled coalition remains; no shared-Duke training, new ruling, other overlays/public starts or conversion. | [Bounded native contract and original runtime/human evidence](DISCOVERY_PROTOTYPE.md#native-leader-skills-composition--6-october-2026), [Smuggler capture repair](SMUGGLER_BATTLE.md#native-discovery-legal-participation--6-october-2026) | diff --git a/game/engine.ts b/game/engine.ts index 1dfb5297..d149bf5a 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -25685,8 +25685,9 @@ const RICHESE_CUNNING_ROSTER: Partial> = { beneGesserit: true, guild: true, richese: true, choam: true, }; function richeseCunningModeSupported(g: Game) { + const skills = !!g.leaderSkills && pairedNexusLeaderSkillsProfile(g); return pairedDiscoveryNexusProfile(g) || - (!g.homeworlds && (!g.leaderSkills || pairedNexusLeaderSkillsProfile(g)) && !g.discoveryEnabled && + (!g.homeworlds && (!g.leaderSkills || skills) && (!g.discoveryEnabled || skills) && !g.ecazTreachery && !g.sandtrout && g.players.every(player => RICHESE_CUNNING_ROSTER[player.faction] === true)); } diff --git a/game/leader-skill-profile.ts b/game/leader-skill-profile.ts index 987afb85..ecffcdfe 100644 --- a/game/leader-skill-profile.ts +++ b/game/leader-skill-profile.ts @@ -65,9 +65,9 @@ export function classicNexusLeaderSkillsProfile(game: LeaderSkillProfile): boole return true; } -/** Both original natives in one E1/E2 family, with original Tech/Advanced Strongholds; other overlays stay separate. */ +/** Both original natives in one E1/E2 family, with original Discovery/Tech/Advanced Strongholds. */ export function pairedNexusLeaderSkillsProfile(game: LeaderSkillProfile): boolean { - if (game.expansions.length !== 1 || !nexusLeaderSkillModulesSupported(game)) return false; + if (game.expansions.length !== 1 || !nexusLeaderSkillModulesSupported(game, false, true)) return false; const expansion = game.expansions[0]; if (expansion !== 'ix' && expansion !== 'choam') return false; const primary = expansion === 'ix' ? 'ixians' : 'choam'; @@ -86,10 +86,10 @@ export function pairedNexusLeaderSkillsProfile(game: LeaderSkillProfile): boolea return hasPrimary && hasSecondary; } -/** One original E3 native with classics; no shared-Duke assignment or mixed family. */ +/** One original E3 native with classics and original Discovery; no shared-Duke assignment or mixed family. */ export function standaloneE3NexusLeaderSkillsProfile(game: LeaderSkillProfile): boolean { if (game.expansions.length !== 1 || game.expansions[0] !== 'ecaz' || - !nexusLeaderSkillModulesSupported(game, game.advanced === true)) return false; + !nexusLeaderSkillModulesSupported(game, game.advanced === true, true)) return false; const players = game.players!; let native: 'ecaz' | 'moritani' | null = null, harkonnen = false; for (let i = 0; i < players.length; i++) { diff --git a/game/reference.ts b/game/reference.ts index be273f43..a9961494 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -4081,6 +4081,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 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.', 'The fresh Leader Skills entry may explicitly add Discoveries with the original classic or supported native family decks, all fourteen skill components, optional original Tech at three or more seats and Advanced Stronghold Cards. Classic setups additionally accept --nexus-cards, retaining all12 and 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 starting hands and offered training are preserved. Physical free entry, nested tariffs, stash capacity, typed rescue, native aftermath and printed invoices use original sources. Native Skills/Nexus/Discovery, Homeworlds, independent variants and separate Banker/Mentat previews remain outside this composition.', + 'The same fresh entry also adds --nexus-cards --discoveries to paired Ixians+Tleilaxu, paired CHOAM+Richese or standalone Ecaz OR Moritani with classics: original 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. E2 keeps the signed two-marker frame and one invoice. Ecaz keeps quiet living/unclaimed Duke separate from training and a real owner-labelled mandatory Occupy; Moritani keeps skill-first assassination and one private Mentat replacement. Mixed/E3 pairs, Homeworlds, shared Duke, borrowed Smuggler/pair companions, native free returns, captured/foreign training, Basic odd Ecaz and Advanced Harkonnen stay separate.', 'A separate fresh Nexus entry may explicitly add Discoveries to classic factions or both native Ixians/Tleilaxu or CHOAM/Richese with classics, one original family deck and no Skills. Original Tech requires three or more seats; Stronghold Cards require Advanced. 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 return ledgers, Guild-priced nested shipment and signed native one-invoice marker pairs retain physical pieces and original capacities. E3/mixed families, native Skills/Nexus/Discovery, Homeworlds, unrelated previews and unresolved mixed-module Fremen Betrayal stay separate.', '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.', @@ -4645,7 +4646,8 @@ export const RULE_TOPICS: RuleTopic[] = [ '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.', 'Fresh Advanced classic or supported native two-through-six-seat skill tables may retain six original Stronghold Cards, with Tech at three or more seats. Original end-Mentat/local held effects remain. Arrakeen changes actual payer cost, not rescue or Banker eligibility; native CHOAM income retains bank support. Physical defenses, normal disciplines, holder ties and original dial/Worthless earnings remain. Standalone Ecaz Duke and Moritani assassination compose original rules; mixed E3, allied Occupy skills, copied Diplomat defenses, active skill/Stone and other guards stay separate.', - 'Fresh classic Leader Skills may explicitly add Nexus through local leader-skills --nexus-cards, Basic/Advanced two through six players, retaining selected original Tech at three or more seats and/or Advanced Strongholds. --discoveries additionally composes original Discovery7/8 in this classic profile. Saved hands/offers remain authoritative; the actual end-Spice closing draw follows Maker votes/typed rides, both Advanced piles and settled alliance. Free-return ledgers, bank purchase and Traitor Deck declarations stay distinct from training. Original phase-end industry, held payer support/physical defenses, physical Suk rescue and subsequent mandatory winner Tech rewards retain their own timing. Native Skills/Nexus/Discovery, Homeworlds, Banker/Mentat previews, borrowed Smuggler arithmetic and unresolved interactions remain separate.', + 'Fresh classic Leader Skills may explicitly add Nexus through local leader-skills --nexus-cards, Basic/Advanced two through six players, retaining selected original Tech at three or more seats and/or Advanced Strongholds. --discoveries additionally composes original Discovery7/8 in this classic profile. Saved hands/offers remain authoritative; the actual end-Spice closing draw follows Maker votes/typed rides, both Advanced piles and settled alliance. Free-return ledgers, bank purchase and Traitor Deck declarations stay distinct from training. Original phase-end industry, held payer support/physical defenses, physical Suk rescue and subsequent mandatory winner Tech rewards retain their own timing. Homeworlds, Banker/Mentat previews, borrowed Smuggler arithmetic and unresolved interactions remain separate.', + 'The same fresh entry also adds --nexus-cards --discoveries to paired Ixians+Tleilaxu, paired CHOAM+Richese or standalone Ecaz OR Moritani with classics, original all14/all12/Discovery7/8, Basic/Advanced two through six with optional Tech3+ and Advanced Strongholds. Original offers/hands/native components remain authoritative. E1 Suboid Cunning settles typed Suk rescue before one equal Cyborg Tank substitution, then original cleanup and mandatory winner Tech precede a matching Face Dance; E2 keeps its signed one-invoice pair; Ecaz keeps quiet living/unclaimed Duke then a real owner-labelled mandatory 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 Ecaz, Advanced Harkonnen and other pending rulings stay guarded.', 'Both original paired Ixian/Tleilaxu or CHOAM/Richese factions plus classics may add Nexus through that fresh local skill entry, with one family deck/all14/all12, Basic/Advanced two through six players. Selected original Tech requires3+; Strongholds requires Advanced. Full unsupported Suboids retain Cyborg support and skill posture; physical Suk rescue precedes equal actual Cyborg Tank substitution. Original winner cleanup and mandatory Tech precede Face Dance, which returns a matching killed trainer skill once without another bounty. Held custody stays with the original winner. Richese Cunning retains its signed two-marker/one-invoice operation and native phase-end industry; ordinary skill proofs and companion exclusions stay separate. CHOAM fuel precedes ordinary Planetologist movement without a second card effect or artificial combat Special.', 'Standalone Ecaz OR Moritani with classics can add Nexus through the original fresh skill entry, exact ecaz33/all14/all12, Basic/Advanced2–6, optionally preserving original Tech3+ and/or Advanced Strongholds2+. Ordinary assignment discs and native skill bands remain. Ecaz spends actual Cunning before Battle to acquire the living/unclaimed separate Duke for the turn; normal discipline may apply, never a Duke assignment. Moritani retains Advanced skill-first assassination and normal-Traitor forfeiture, printed post-loss bounty and one private actual Mentat replacement, while Basic keeps legal Terror without gaining that Advanced power. Supply-only Cunning placement stays distinct from ordinary relocation; its enhanced relocation/HMS/Grumman questions remain pending. No public or saved-game conversion.', 'In this standalone Ecaz Nexus/Skills profile, mandatory Advanced Occupy keeps the chosen faction’s own leaders, cards, payment and battle skill. Training is not lent between allies. Ecaz-led Suk rescues its own fixed casualties after the ally’s variable losses; Diplomat retreats the selected faction’s undialed own counters, not a remainder computed from the combined dial. Native cancellation restores that selected lead’s ordinary own pool. These owner-labelled compositions are explicit implementation inferences, not a new combined-skill FAQ.', diff --git a/tests/discovery-module-profile.test.ts b/tests/discovery-module-profile.test.ts index 82f1071d..89cdbb14 100644 --- a/tests/discovery-module-profile.test.ts +++ b/tests/discovery-module-profile.test.ts @@ -57,7 +57,7 @@ void test('Discovery Skills cannot silently drop unsupported native families or assert.throws(() => initializeLeaderSkillsGameForAudit(unsupported), RuleError); const overlays = Array.from({ length: 5 }, () => lobby(['guild', 'emperor', 'harkonnen'], [])); overlays[0].homeworlds = { custody: null }; - overlays[1] = lobby(['ixians', 'tleilaxu', 'guild'], ['ix']); + overlays[1] = lobby(['ixians', 'choam', 'guild'], ['ix', 'choam']); overlays[1].nexusCards = { cards: null, phase: null }; overlays[2].advanced = false; overlays[2].strongholdCards = createStrongholdCards(); overlays[3].mentatQuestionPreview = true; diff --git a/tests/discovery-paired-nexus-e1-skills-runtime.test.ts b/tests/discovery-paired-nexus-e1-skills-runtime.test.ts new file mode 100644 index 00000000..68e90797 --- /dev/null +++ b/tests/discovery-paired-nexus-e1-skills-runtime.test.ts @@ -0,0 +1,356 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { gameDistance, MOBILE_LOCATION, splitLocation } from '../game/board'; +import { treacheryDeck } from '../game/cards'; +import { DISCOVERY_SPICE_CARDS, validateDiscoveryState } from '../game/discoveries'; +import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards'; +import { validateLeaderSkills } from '../game/leader-skills'; +import { validateNexusCards } from '../game/nexus-cards'; +import { nexusFaceDancersAction } from '../game/nexus-tleilaxu-options'; +import { ownedTech } from '../game/tech-tokens'; +import { traitorDeck } from '../game/traitors'; +import { pairedDiscoveryNexusClean as clean, placePairedDiscoveryNexus, reservePairedDiscoveryNexusBoard } from './fixture-discovery-paired-nexus'; +import { + advanceDiscoveryPairedNexusE1Skills as advance, beginDiscoveryPairedNexusE1SkillsCunning as cunning, + closeDiscoveryPairedNexusE1Skills as close, createDiscoveryPairedNexusE1SkillsBattle as battle, + createDiscoveryPairedNexusE1SkillsEntry as entry, discoveryPairedNexusE1SkillsPlayer as player, + discoveryPairedNexusE1SkillsRangeSource as rangeSource, finishDiscoveryPairedNexusE1SkillsBattle as finish, + initializeDiscoveryPairedNexusE1SkillsSetup as setup, prepareDiscoveryPairedNexusE1SkillsPlans as prepare, + revealDiscoveryPairedNexusE1SkillsBattle as reveal, stepDiscoveryPairedNexusE1Skills as step, + type DiscoveryPairedNexusE1SkillsBattle, +} from './fixture-discovery-paired-nexus-e1-skills'; + +function reject(game: Game, actor: string, action: Action): void { + const before = structuredClone(game); + assert.throws(() => applyAction(game, actor, action)); + assert.deepEqual(game, before, 'Rejected original action leaves the whole original input untouched'); +} +function custody(game: Game, original: Game): void { + const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand), + ...(game.ixSetupCards ?? []), ...(game.ixAuction?.cards ?? []), + ...(game.auction?.cards.slice(game.auction.index) ?? [])]; + assert.deepEqual(cards.map(c => c.id).sort(), treacheryDeck(['ix']).map(c => c.id).sort()); + const identities = [...(game.traitorReserve ?? []), ...game.players.flatMap(p => [ + ...p.traitors, ...p.traitorChoices, ...(p.faceDancers ?? []).map(c => c.leader), + ])]; + assert.deepEqual(identities.sort(), traitorDeck(game.players, true).sort()); + validateLeaderSkills(game.leaderSkills!, game.players); + assert.deepEqual([...game.leaderSkills!.deck, ...Object.values(game.leaderSkills!.offers).flatMap(o => o.cards), + ...game.leaderSkills!.assignments.map(a => a.skill)].sort(), LEADER_SKILL_CARDS.map(c => c.id).sort()); + validateDiscoveryState(game.discoveries!); validateNexusCards(game.nexusCards!.cards!, game.players); + for (const p of game.players) { + assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum, n) => sum + n, 0), 20); + if (p.elites) { + const previous = player(original, p.id).elites!; + assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0), + previous.reserves + previous.tanks + Object.values(previous.forces).reduce((sum, n) => sum + n, 0)); + assert.ok(p.elites.reserves <= p.reserves && p.elites.tanks <= p.tanks); + for (const [key, n] of Object.entries(p.elites.forces)) assert.ok(n <= (p.forces[key] ?? 0)); + } + } +} +/** Select real Cunning commitment, never assign a loss receipt. */ +function rescueWindow(f: DiscoveryPairedNexusE1SkillsBattle, revealed = reveal(f)): Game { + let game = finish(revealed, 'losses'); + if (game.decision?.kind === 'battleLosses') { + const choice = game.decision.options.findIndex(o => o.normal === 4 && o.elite === 2); assert.ok(choice >= 0); + game = step(game, { actor: game.decision.player, action: { type: 'decision', choice } }); + } + game = finish(game, 'rescue'); + assert.equal(game.decision?.kind, 'sukRescue'); + return game; +} +function rescueAndSubstitute(f: DiscoveryPairedNexusE1SkillsBattle, pending: Game): Game { + assert.ok(pending.decision?.kind === 'sukRescue'); + const choice = pending.decision.options.findIndex(o => o.normal === 2 && o.elite === 1 && o.kept?.kind === 'normal'); + assert.ok(choice >= 0); + const rescued = step(pending, { actor: f.owner, action: { type: 'decision', event: pending.decision.event, choice } }); + assert.equal(rescued.decision?.kind, 'ixSubstitution'); + return advance(step(rescued, { actor: f.owner, action: { type: 'decision', + sources: { [f.location]: 1 }, recover: { [f.location]: 1 } } }), + game => clean(game) || game.decision?.kind === 'battleCards' || game.decision?.kind === 'faceDance'); +} + +const MODES = [ + { advanced: false, tech: false, strongholds: false }, + { advanced: false, tech: true, strongholds: false }, + { advanced: true, tech: false, strongholds: false }, + { advanced: true, tech: true, strongholds: false }, + { advanced: true, tech: false, strongholds: true }, + { advanced: true, tech: true, strongholds: true }, +] as const; +for (const mode of MODES) void test(`paired E1 Skills ${JSON.stringify(mode)}: original hands, printed collection, END Mentat, Maker and qualified closing`, () => { + const f = entry(mode); + assert.deepEqual(f.offered.players.map(p => [p.id, p.hand]), f.afterSetup.players.map(p => [p.id, p.hand])); + assert.ok(f.offered.players.every(p => !p.traitors.length && !p.traitorChoices.length)); + assert.equal(player(f.offered, f.native).faceDancers, undefined); + assert.deepEqual(f.setup.spiceDeck.flatMap(c => 'territory' in c && c.discovery ? [c.discovery] : []).sort(), + DISCOVERY_SPICE_CARDS.map(c => c.discovery).sort()); + assert.equal(f.setup.discoveries!.tokens.length, 8); + assert.equal(f.setup.spiceDeck.filter(c => 'worm' in c && c.greatMaker).length, 1); + assert.equal(f.setup.spiceDeck.filter(c => 'sandtrout' in c && c.sandtrout).length, 1); + assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 0); assert.equal(f.game.decision?.kind, 'discoveryEntry'); + assert.ok(Object.values(f.game.nexusCards!.cards!.hands).every(c => c === null)); + assert.equal(player(f.game, f.owner).forces[f.source], 3); + assert.equal(player(f.game, f.owner).elites!.forces[f.source], 1); + if (mode.strongholds) { + assert.equal(f.firstMentat.before.strongholdCards!.owners.arrakeen, null); + assert.equal(f.firstMentat.after.strongholdCards!.owners.arrakeen, f.owner); + } + const c = close(f), before = player(c.entry.before, f.owner), arrived = player(c.entry.after, f.owner); + assert.equal(arrived.forces['shrine:0'], 2); assert.equal(arrived.elites!.forces['shrine:0'], 1); + assert.equal(arrived.forces[f.source], 1); assert.equal(arrived.elites!.forces[f.source] ?? 0, 0); + assert.equal(arrived.spice, before.spice); assert.equal(arrived.reserves, before.reserves); + assert.equal(arrived.shipped, before.shipped); assert.equal(arrived.moved, before.moved); + assert.deepEqual(c.entry.after.leaderSkills, c.entry.before.leaderSkills); + assert.equal(player(c.vote, f.native).tanks, player(f.game, f.native).tanks + 2); + assert.equal(player(c.vote, f.native).forces['hagga_basin:12'] ?? 0, 0); + assert.ok(Object.values(c.vote.nexusCards!.cards!.hands).every(card => card === null)); + assert.equal(player(c.ride.after, f.fremen).forces['polar_sink:0'], 2); + assert.equal(player(c.ride.after, f.fremen).elites?.forces['polar_sink:0'] ?? 0, mode.advanced ? 1 : 0); + assert.equal(player(c.ride.after, f.fremen).spice, player(c.ride.before, f.fremen).spice); + assert.notEqual(c.ride.after.nexusCards!.phase?.stage, 'drawing'); + assert.equal(player(c.drawing, f.guild).ally, f.fremen); assert.equal(player(c.drawing, f.fremen).ally, f.guild); + assert.deepEqual([...c.drawing.nexusCards!.phase!.eligible].sort(), [f.owner, f.native].sort()); + if (mode.advanced) assert.ok(c.drawing.spiceDiscard[1].some(card => 'territory' in card && card.territory === 'rock_outcroppings')); + assert.equal(c.afterDraw.nexusCards!.cards!.hands[f.owner], 'ixians'); + assert.equal(c.afterDraw.nexusCards!.cards!.hands[f.native], 'tleilaxu'); + assert.equal(c.afterDraw.nexusCards!.cards!.hands[f.guild], null); + assert.equal(c.afterDraw.nexusCards!.cards!.hands[f.fremen], null); + for (const game of [f.afterSetup, f.game, c.entry.after, c.vote, c.ride.after, c.afterDraw]) custody(game, f.afterSetup); +}); + +void test('paired E1 authenticated original human actors, existing first hands and actual offers survive continuation without requiring Suk', () => { + const lobby = createGame('HUMANPAIREDE1SKILLS', newPlayer('human-ix', 'Human Ix', 'ixians'), true, ['ix']); + for (const [id, faction] of [['human-tl', 'tleilaxu'], ['human-gu', 'guild'], ['human-fr', 'fremen']] as const) + joinGame(lobby, newPlayer(id, faction, faction)); + const fresh = setup({ initial: lobby, skill: 'planetologist', tech: true, strongholds: true }); + assert.deepEqual(fresh.initial, lobby); + const original = structuredClone(fresh.offered), offered = original.leaderSkills!.offers[fresh.owner]; + const continued = entry({ initial: original }); + assert.deepEqual(continued.initial, original); + assert.deepEqual(continued.offered.leaderSkills!.offers, original.leaderSkills!.offers); + assert.deepEqual(continued.afterSetup.players.map(p => [p.id, p.name, p.hand]), original.players.map(p => [p.id, p.name, p.hand])); + assert.deepEqual(continued.afterSetup.players.map(p => p.id), ['human-ix', 'human-tl', 'human-gu', 'human-fr']); + assert.ok(offered.cards.includes(continued.skill)); + reject(original, fresh.owner, { type: 'leaderSkill', event: offered.event, skill: offered.cards[0], + leader: player(original, fresh.native).leaders[0].id }); + const absent = LEADER_SKILL_CARDS.find(c => !offered.cards.includes(c.id))!.id; + assert.throws(() => entry({ initial: original, skill: absent })); + assert.deepEqual(original, fresh.offered); + custody(continued.game, continued.afterSetup); +}); + +void test('paired E1 source-clear free entry rejects foreign actors and HMS companions; real Planetologist crosses the printed nested range', () => { + const f = entry({ advanced: true, tech: true, strongholds: true, skill: 'planetologist' }); + assert.ok(f.game.decision?.kind === 'discoveryEntry'); + const action: Action = { type: 'decision', event: f.game.decision.event, accept: true, + groups: [{ source: f.source, normal: 1, elite: 1 }] }; + reject(f.game, f.native, action); + reject(f.game, f.owner, { ...action, groups: [{ source: f.source, normal: 1, elite: 2 }] }); + reject(f.game, f.owner, { ...action, groups: [{ source: MOBILE_LOCATION, normal: 1, elite: 1 }] }); + const entered = step(f.game, { actor: f.owner, action }); + reject(entered, f.owner, action); + const c = close(f); + let game = advance(c.afterDraw, g => g.phase === 5 && g.active === f.owner && clean(g)); + reservePairedDiscoveryNexusBoard(game, [MOBILE_LOCATION]); + const source = rangeSource(game); + assert.equal(gameDistance(game, source, 'shrine:0', key => splitLocation(key).sector === game.storm), 3); + placePairedDiscoveryNexus(game, f.owner, source, 3, 1); + const before = structuredClone(game), move: Action = { type: 'move', territory: 'shrine', sector: 0, + forces: { [source]: 2 }, eliteForces: { [source]: 1 }, planetologist: 'range' }; + reject(game, f.owner, { ...move, planetologist: undefined }); + reject(game, f.owner, { ...move, territory: 'shrine', sector: 1 }); + game = advance(step(game, { actor: f.owner, action: move }), clean); + assert.equal(player(game, f.owner).forces['shrine:0'], 2); + assert.equal(player(game, f.owner).elites!.forces['shrine:0'], 1); + assert.equal(player(game, f.owner).forces[source], 1); + assert.equal(player(game, f.owner).moved, 1); + assert.equal(player(game, f.owner).spice, player(before, f.owner).spice); + assert.equal(player(game, f.owner).forces[MOBILE_LOCATION], player(before, f.owner).forces[MOBILE_LOCATION]); + assert.deepEqual(game.leaderSkills, before.leaderSkills); + custody(game, f.afterSetup); +}); + +for (const mode of MODES) void test(`paired E1 Skills ${JSON.stringify(mode)}: full Cunning, trained Suk rescue then genuine equal Cyborg substitution`, () => { + const f = battle(mode), prepared = prepare(f), offer = viewGame(prepared, f.owner).nexusSuboids!.offer!; + reject(prepared, f.opponent, { type: 'nexusSuboids', event: offer.event }); + const sealed = step(prepared, f.plans[1]); + const ownSealed = step(prepared, { actor: f.owner, action: { ...f.plans[0].action, dial: 6, support: mode.advanced ? 1 : 0 } }); + reject(ownSealed, f.owner, { type: 'nexusSuboids', event: offer.event }); + const active = cunning(f, sealed); + assert.equal(active.nexusCards!.cards!.hands[f.owner], null); + assert.equal(active.nexusCards!.cards!.discard.filter(c => c === 'ixians').length, 1); + assert.deepEqual(player(active, f.owner).forces, player(sealed, f.owner).forces); + reject(active, f.owner, { type: 'nexusSuboids', event: offer.event }); + const revealed = reveal(f), pending = rescueWindow(f, revealed); + assert.equal(viewGame(revealed, f.owner).battle!.strongholdEffects[f.owner], null, + 'An actually retained named Arrakeen Card cannot subsidize the nested site'); + assert.equal(pending.lastBattleContext!.winner, f.owner); + assert.deepEqual(pending.pendingSukRescue!.losses, + { normal: 4, elite: 2, paidNormal: 0, paidElite: mode.advanced ? 2 : 0 }); + assert.equal(player(pending, f.owner).forces[f.location], 8); + assert.equal(player(pending, f.owner).tanks, player(revealed, f.owner).tanks); + assert.equal(pending.pendingIxSubstitution, undefined); + if (mode.strongholds) assert.equal(pending.strongholdCards!.owners.arrakeen, f.owner); + const choice = pending.decision!.kind === 'sukRescue' + ? pending.decision.options.findIndex(o => o.normal === 2 && o.elite === 1 && o.kept?.kind === 'normal') : -1; + assert.ok(choice >= 0); + reject(pending, f.owner, { type: 'decision', sources: { [f.location]: 1 }, recover: { [f.location]: 1 } }); + const saved = step(pending, { actor: f.owner, action: { type: 'decision', event: pending.decision!.kind === 'sukRescue' ? pending.decision.event : '', choice } }); + assert.equal(saved.lastBattleContext!.sukRescue!.completed, true); + assert.equal(saved.decision?.kind, 'ixSubstitution'); + assert.equal(player(saved, f.owner).forces[f.location], 3); + assert.equal(player(saved, f.owner).elites!.forces[f.location] ?? 0, 0); + assert.equal(player(saved, f.owner).reserves, player(pending, f.owner).reserves + 2); + assert.equal(player(saved, f.owner).elites!.reserves, player(pending, f.owner).elites!.reserves + 1); + assert.equal(player(saved, f.owner).tanks, player(pending, f.owner).tanks + 3); + assert.equal(player(saved, f.owner).elites!.tanks, player(pending, f.owner).elites!.tanks + 1); + assert.deepEqual(saved.pendingIxSubstitution!.losses, { [f.location]: 1 }); + reject(saved, f.owner, { type: 'decision', sources: { [f.location]: 1 }, recover: { [f.replacementSource]: 1 } }); + reject(saved, f.owner, { type: 'decision', sources: { [f.location]: 2 }, recover: { [f.location]: 2 } }); + const substituted = advance(step(saved, { actor: f.owner, action: { type: 'decision', + sources: { [f.location]: 1 }, recover: { [f.location]: 1 } } }), + game => clean(game) || game.decision?.kind === 'battleCards' || game.decision?.kind === 'faceDance'); + assert.equal(player(substituted, f.owner).forces[f.location], 3); + assert.equal(player(substituted, f.owner).elites!.forces[f.location], 1); + assert.equal(player(substituted, f.owner).elites!.tanks, player(pending, f.owner).elites!.tanks); + assert.equal(player(substituted, f.owner).spice, player(saved, f.owner).spice); + assert.equal(substituted.lastBattleContext!.ixSubstitution!.completed, true); + custody(substituted, f.entry.afterSetup); +}); + +for (const advanced of [false, true]) void test(`paired E1 ${advanced ? 'Advanced' : 'Basic'}: physical winner cleanup and original Tech precede Face Dance, partial Cunning retains Mentat rights`, () => { + const f = battle({ advanced, tech: true, strongholds: advanced }); + assert.ok(f.weapon, 'Controlled original first-hand offer supplies an actually selected weapon'); + const revealed = reveal(f), rescued = rescueAndSubstitute(f, rescueWindow(f, revealed)); + const cards = finish(rescued, 'cards'); assert.equal(cards.decision?.kind, 'battleCards'); + const premature: Action = { type: 'decision', reveal: true, sector: 0, sources: { reserves: 1, [f.replacementSource]: 2 } }; + reject(cards, f.opponent, premature); + assert.equal(cards.lastBattleContext!.sukRescue!.completed, true); + assert.equal(cards.lastBattleContext!.ixSubstitution!.completed, true); + const cleaned = step(cards, { actor: f.owner, action: { type: 'decision', discard: [f.weapon] } }); + const face = finish(cleaned, 'faceDance'); + assert.ok(face.discard.some(c => c.id === f.weapon)); + assert.ok(!player(face, f.owner).hand.some(c => c.id === f.weapon)); + assert.equal(player(face, f.opponent).leaders.find(l => l.id === f.enemy)!.dead, true); + const defeatedTech = ownedTech(revealed.techTokens, f.opponent); assert.equal(defeatedTech.length, 1); + assert.equal(face.techTokens![defeatedTech[0]].owner, f.owner); + assert.ok(!face.pendingTech); + const winner = structuredClone(player(face, f.owner)), native = structuredClone(player(face, f.opponent)); + const dance: Action = { type: 'decision', reveal: true, sector: 0, sources: { reserves: 1, [f.replacementSource]: 2 } }; + reject(face, f.opponent, { ...dance, sector: 1 }); + reject(face, f.opponent, { ...dance, sources: { [MOBILE_LOCATION]: 3 } }); + reject(face, f.opponent, { ...dance, sources: { reserves: 4 } }); + const danced = step(face, { actor: f.opponent, action: dance }); + assert.equal(player(danced, f.owner).leaders.find(l => l.id === f.trainer)!.dead, true); + assert.equal(danced.leaderSkills!.assignments.some(a => a.leader === f.trainer), false); + assert.equal(danced.leaderSkills!.deck.filter(s => s === f.skill).length, 1); + assert.equal(player(danced, f.owner).forces[f.location] ?? 0, 0); + assert.equal(player(danced, f.owner).reserves, winner.reserves + 3); + assert.equal(player(danced, f.owner).elites!.reserves, winner.elites!.reserves + 1); + assert.equal(player(danced, f.opponent).forces[f.location], 3); + assert.equal(player(danced, f.opponent).forces[f.replacementSource], 1); + assert.equal(player(danced, f.opponent).reserves, native.reserves - 1); + assert.deepEqual(danced.techTokens, face.techTokens); + assert.deepEqual(player(danced, f.owner).hand, winner.hand); + for (const p of face.players) assert.equal(player(danced, p.id).spice, p.spice); + reject(danced, f.opponent, dance); + const projection = viewGame(danced, f.opponent); + const renew = nexusFaceDancersAction(projection, projection.nexusTleilaxu!.cunning!.event); assert.ok(renew); + const revealedDancer = player(danced, f.opponent).faceDancers!.find(c => c.leader === f.trainer)!; + assert.equal(revealedDancer.revealed, true); + reject(danced, f.opponent, { ...renew, leaders: [f.trainer] }); + const reserve = [...danced.traitorReserve!], allowance = player(danced, f.opponent).faceDancerReplacedTurn; + const renewed = step(danced, { actor: f.opponent, action: renew }); + assert.deepEqual(player(renewed, f.opponent).faceDancers!.filter(c => !native.faceDancers!.some(old => old.leader === c.leader)), + [{ leader: reserve[0], revealed: false }]); + assert.deepEqual([...renewed.traitorReserve!].sort(), [...reserve.slice(1), f.trainer].sort()); + assert.equal(player(renewed, f.opponent).faceDancerReplacedTurn, allowance); + assert.equal(renewed.nexusCards!.cards!.hands[f.opponent], null); + assert.equal(renewed.nexusCards!.cards!.discard.filter(c => c === 'tleilaxu').length, 1); + assert.equal(renewed.leaderSkills!.assignments.some(a => a.owner === f.opponent && a.leader === f.trainer), false); + reject(renewed, f.opponent, renew); + reservePairedDiscoveryNexusBoard(renewed, [f.location, f.replacementSource, MOBILE_LOCATION]); + const mentat = advance(renewed, g => g.phase === 8 && clean(g)); + const untouched = player(mentat, f.opponent).faceDancers!.find(c => !c.revealed && c.leader !== reserve[0]); assert.ok(untouched); + const replaced = advance(step(mentat, { actor: f.opponent, action: { type: 'replaceFaceDancer', leader: untouched.leader } }), clean); + assert.equal(player(replaced, f.opponent).faceDancerReplacedTurn, replaced.turn); + reject(replaced, f.opponent, { type: 'replaceFaceDancer', leader: player(replaced, f.opponent).faceDancers![0].leader }); + const nextTurn = advance(replaced, g => g.turn > replaced.turn); + assert.equal(viewGame(nextTurn, f.owner).nexusSuboids!.active, false); + assert.equal(nextTurn.leaderSkills!.deck.filter(s => s === f.skill).length, 1); + if (!advanced) { + const revival = advance(nextTurn, g => g.turn === 3 && g.phase === 4 && clean(g)); + reject(revival, f.opponent, { type: 'reviveForeignGhola', leader: f.trainer }); + assert.equal(player(revival, f.owner).leaders.find(l => l.id === f.trainer)!.dead, true); + assert.equal(revival.leaderSkills!.assignments.some(a => a.leader === f.trainer), false); + custody(revival, f.entry.afterSetup); + } + custody(danced, f.entry.afterSetup); custody(replaced, f.entry.afterSetup); custody(nextTurn, f.entry.afterSetup); +}); + +void test('all four minimal paired E1 policies use eligible original actors and produce entry, vote, Cunning, trained rescue and Face Dance receipts', () => { + const f = entry(), c = close(f), b = battle(), prepared = prepare(b), revealed = reveal(b); + const pending = rescueWindow(b, revealed); + const face = finish(rescueAndSubstitute(b, pending), 'faceDance'); + for (const difficulty of DIFFICULTIES) { + for (const current of [ + { game: f.game, actor: f.owner, type: 'decision' }, + { game: c.vote, actor: c.vote.decision!.player, type: 'decision' }, + { game: c.ride.before, actor: f.fremen, type: 'decision' }, + { game: c.drawing, actor: f.owner, type: 'nexusCardChoice' }, + { game: prepared, actor: b.owner, type: 'nexusSuboids' }, + { game: pending, actor: b.owner, type: 'decision' }, + { game: face, actor: b.opponent, type: 'decision' }, + ]) { + const projection = viewGame(current.game, current.actor); + projection.players.find(p => p.id === current.actor)!.bot = difficulty; + const action = botActions(projection).find(a => a.type === current.type); assert.ok(action); + const acted = step(current.game, { actor: current.actor, action }); + const after = current.type === 'nexusSuboids' ? advance(acted, clean) : acted; + if (current.game.decision?.kind === 'discoveryEntry') { + assert.equal(after.discoveryEntry, undefined); + const own = player(after, current.actor), old = player(current.game, current.actor); + assert.equal((own.forces[f.source] ?? 0) + (own.forces['shrine:0'] ?? 0), old.forces[f.source]); + assert.equal((own.elites!.forces[f.source] ?? 0) + (own.elites!.forces['shrine:0'] ?? 0), 1); + assert.equal(own.spice, old.spice); + } else if (current.game.decision?.kind === 'greatMakerVote') + assert.equal(after.greatMaker!.votes.length, current.game.greatMaker!.votes.length + 1); + else if (current.game.decision?.kind === 'greatMakerRide') { + assert.equal(after.greatMaker!.stage, 'complete'); + assert.equal(player(after, f.fremen).spice, player(current.game, f.fremen).spice); + } else if (current.type === 'nexusCardChoice') { + assert.ok(after.nexusCards!.phase!.done.includes(current.actor)); + assert.equal(after.nexusCards!.cards!.hands[current.actor], 'ixians'); + assert.equal(after.nexusCards!.cards!.deck.length, current.game.nexusCards!.cards!.deck.length - 1); + } else if (current.type === 'nexusSuboids') { + assert.equal(after.nexusCards!.cards!.hands[b.owner], null); + assert.equal(after.nexusCards!.cards!.discard.filter(card => card === 'ixians').length, 1); + assert.deepEqual(player(after, b.owner).forces, player(current.game, b.owner).forces); + assert.equal(viewGame(after, b.owner).nexusSuboids!.active, true); + const own = viewGame(after, b.owner); own.players.find(p => p.id === b.owner)!.bot = difficulty; + const plan = botActions(own).find(a => a.type === 'battlePlan'); assert.ok(plan); + const committed = step(after, { actor: b.owner, action: plan }); + assert.equal(committed.battle!.plans[b.owner].leader, plan.leader); + assert.equal(player(committed, b.owner).spice, player(after, b.owner).spice); + custody(committed, b.entry.afterSetup); + } else if (current.game.decision?.kind === 'sukRescue') { + assert.equal(after.pendingSukRescue, null); + assert.equal(after.lastBattleContext!.sukRescue!.completed, true); + assert.equal(player(after, b.owner).forces[b.location], 3); + assert.equal(player(after, b.owner).tanks, player(pending, b.owner).tanks + 3); + assert.equal(player(after, b.owner).reserves, player(pending, b.owner).reserves + 2); + } else { + assert.equal(player(after, b.owner).forces[b.location] ?? 0, 0); + assert.equal(player(after, b.owner).leaders.find(l => l.id === b.trainer)!.dead, true); + assert.equal(after.leaderSkills!.assignments.some(a => a.leader === b.trainer), false); + assert.deepEqual(after.techTokens, face.techTokens); + } + custody(after, current.actor === b.owner || current.actor === b.opponent ? b.entry.afterSetup : f.afterSetup); + } + } +}); diff --git a/tests/discovery-paired-nexus-e2-skills-runtime.test.ts b/tests/discovery-paired-nexus-e2-skills-runtime.test.ts new file mode 100644 index 00000000..313994db --- /dev/null +++ b/tests/discovery-paired-nexus-e2-skills-runtime.test.ts @@ -0,0 +1,384 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, createGame, handLimit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { treacheryDeck, spiceDeck } from '../game/cards'; +import type { Card } from '../game/cards'; +import { richeseCards } from '../game/richese-cards'; +import { validGameLocation, territory } from '../game/board'; +import { validateDiscoveryState } from '../game/discoveries'; +import { validateLeaderSkills } from '../game/leader-skills'; +import { validateNexusCards } from '../game/nexus-cards'; +import { validateRicheseNoField } from '../game/richese-no-field'; +import { ownedTech } from '../game/tech-tokens'; +import { placePairedSkillsForce, stagePairedSkillsPlanetologistRoute } from './fixture-paired-choam-nexus-skills'; +import { + advanceDiscoveryPairedNexusE2Skills as advance, closeDiscoveryPairedNexusE2Skills as close, + completeDiscoveryPairedNexusE2SkillsSetup as completeSetup, + createDiscoveryPairedNexusE2SkillsFixture as fixture, + discoveryPairedNexusE2SkillsClean as clean, + discoveryPairedNexusE2SkillsCunningAction as pairAction, + discoveryPairedNexusE2SkillsInvoiceAction as invoice, + discoveryPairedNexusE2SkillsPlayer as player, + finishDiscoveryPairedNexusE2SkillsBattle as finishBattle, + initializeDiscoveryPairedNexusE2Skills as initialize, + prepareDiscoveryPairedNexusE2SkillsBattle as prepareBattle, + settleDiscoveryPairedNexusE2Skills as settle, + stepDiscoveryPairedNexusE2Skills as step, + type DiscoveryPairedNexusE2SkillsFixture, +} from './fixture-discovery-paired-nexus-e2-skills'; + +const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)); +function reject(game: Game, actor: string, action: Action): void { + const before = structuredClone(game); + assert.throws(() => applyAction(game, actor, action)); + assert.deepEqual(game, before, 'Rejected human declarations cannot spend cards, invoice, counters or training.'); +} +function custody(game: Game): void { + const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand), + ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []), + ...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? [])]; + assert.deepEqual(cards.map(c => c.id).sort(), [...treacheryDeck(['choam']), ...richeseCards()].map(c => c.id).sort()); + validateLeaderSkills(game.leaderSkills!, game.players); + validateNexusCards(game.nexusCards!.cards!, game.players); + validateDiscoveryState(game.discoveries!); + for (const p of game.players) { + assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((n, count) => n + count, 0), 20); + if (p.elites) assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((n, count) => n + count, 0), p.faction === 'fremen' ? 3 : 5); + if (p.noField) validateRicheseNoField(p.noField); + } +} +function acquiredCard(f: DiscoveryPairedNexusE2SkillsFixture, buyer: string, matches: (card: Card) => boolean): Card { + const receipt = f.acquisitions.find(a => a.buyer === buyer && matches(a.card)); + assert.ok(receipt, 'The intended eligible actor must actually acquire this original card through Bidding.'); + const { before, after, card } = receipt, auction = before.auction!; + assert.equal(before.phase, 3); + assert.equal(before.turn, 1); + assert.equal(auction.cards[auction.index].id, card.id); + assert.equal(auction.bidder, buyer); + assert.equal(auction.bid, 1); + assert.ok(player(before, buyer).hand.length < handLimit(player(before, buyer))); + assert.ok(f.afterSetup.deck.some(c => c.id === card.id)); + assert.deepEqual(player(after, buyer).hand, [...player(before, buyer).hand, card]); + assert.equal(player(after, buyer).spice, player(before, buyer).spice - 1); + assert.ok(f.actions.some(s => s.actor === buyer && s.action.type === 'bid' && s.action.amount === 1)); + return card; +} +const modules = [ + { advanced: false, tech: false, strongholds: false }, + { advanced: false, tech: true, strongholds: false }, + { advanced: true, tech: false, strongholds: false }, + { advanced: true, tech: true, strongholds: false }, + { advanced: true, tech: false, strongholds: true }, + { advanced: true, tech: true, strongholds: true }, +] as const; + +for (const options of modules) void test(`original paired E2 Discovery/Skills ${JSON.stringify(options)}: paid collection, real end-Mentat entry, Maker and qualified closing draws`, () => { + const f = fixture(options), ending = close(f); + const ordinary = [...f.initial.deck, ...f.initial.players.flatMap(p => p.hand)]; + assert.equal(ordinary.length, 35); + assert.deepEqual(ordinary.map(c => c.id).sort(), treacheryDeck(['choam']).map(c => c.id).sort()); + assert.equal(f.initial.richeseCache!.length, 10); + assert.equal(f.initial.spiceDeck.length, spiceDeck().length + 7); + assert.equal(f.initial.discoveries!.tokens.length, 8); + assert.equal(f.initial.leaderSkills!.deck.length + Object.values(f.initial.leaderSkills!.offers).reduce((n, o) => n + o.cards.length, 0), 14); + assert.equal(f.initial.nexusCards!.cards!.deck.length, 12); + assert.equal(player(f.previousMarker.after, f.richese).spice, player(f.previousMarker.before, f.richese).spice - 1); + assert.equal(player(f.previousMarker.after, f.richese).reserves, player(f.previousMarker.before, f.richese).reserves); + assert.equal(player(f.collection, f.collector).forces[f.source], 3); + assert.equal(validGameLocation(f.collection, 'shrine', 0), false); + assert.ok(f.firstMentat); + assert.equal(f.firstMentat.before.turn, 1); + assert.equal(f.firstMentat.before.phase, 8); + assert.equal(f.firstMentat.after.turn, 2); + assert.equal(ending.entry.before.phase, 0); + assert.equal(ending.entry.before.decision?.player, f.collector); + assert.equal(player(ending.entry.after, f.collector).forces[f.source] ?? 0, 0); + assert.equal(player(ending.entry.after, f.collector).forces['shrine:0'], 3); + assert.equal(player(ending.entry.after, f.collector).spice, player(ending.entry.before, f.collector).spice); + assert.equal(validGameLocation(ending.game, 'shrine', 0), true); + assert.equal(player(ending.vote, f.choam).forces['hagga_basin:12'] ?? 0, 0); + assert.equal(player(ending.vote, f.choam).tanks, player(ending.entry.before, f.choam).tanks + 2); + assert.equal(ending.ride.before.decision?.player, f.fremen); + assert.equal(player(ending.ride.after, f.fremen).forces['polar_sink:0'], 2); + assert.equal(player(ending.ride.after, f.fremen).reserves, player(ending.ride.before, f.fremen).reserves - 2); + if (options.advanced) assert.equal(player(ending.ride.after, f.fremen).elites!.forces['polar_sink:0'], 1); + assert.equal(ending.alliance.spiceWindow, null); + assert.equal(ending.alliance.spiceResolution, null); + assert.equal(player(ending.alliance, f.guild).ally, f.fremen); + assert.equal(player(ending.alliance, f.fremen).ally, f.guild); + assert.equal(ending.drawing.phase, 1); + for (const draw of ending.draws) { + assert.equal(draw.before.nexusCards!.cards!.hands[draw.step.actor], null); + assert.equal(draw.after.nexusCards!.cards!.hands[draw.step.actor], player(draw.after, draw.step.actor).faction); + reject(draw.after, draw.step.actor, draw.step.action); + } + assert.equal(ending.game.nexusCards!.cards!.hands[f.guild], null); + assert.equal(ending.game.nexusCards!.cards!.hands[f.fremen], null); + assert.deepEqual(ending.game.leaderSkills!.assignments, f.afterSetup.leaderSkills!.assignments); + if (options.strongholds) { + assert.equal(f.firstMentat.before.strongholdCards!.owners.arrakeen, null); + assert.equal(f.firstMentat.after.strongholdCards!.owners.arrakeen, f.choam); + } + custody(ending.game); +}); + +void test('saved authenticated original skill offers and starting hands survive continuation without demanding a random consumer', () => { + const setup = initialize({ advanced: true, tech: true, strongholds: true, + seatIds: ['human-richese', 'human-choam', 'human-guild', 'human-fremen'] }); + const before = structuredClone(setup), continued = initialize({ initial: reload(setup) }); + assert.deepEqual(continued, before); + const done = completeSetup(continued); + for (const assignment of done.leaderSkills!.assignments) + assert.ok(before.leaderSkills!.offers[assignment.owner].cards.includes(assignment.skill)); + assert.deepEqual(done.players.map(p => [p.id, p.hand]), before.players.map(p => [p.id, p.hand])); + assert.equal(Object.keys(done.leaderSkills!.offers).length, 0); + assert.equal(done.status, 'playing'); + assert.equal(done.leaderSkills!.assignments.length, 4); + custody(done); +}); + +for (const advanced of [false, true]) for (const seats of [2, 3, 4, 5, 6]) void test(`${advanced ? 'Advanced' : 'Basic'} human ${seats}-seat original paired programme trains only original offers with legal native modules`, () => { + const factions = ['richese', 'choam', 'guild', 'fremen', 'emperor', 'atreides'] as const; + const lobby = createGame(`HUMANE2${seats}`, newPlayer('authenticated-r', 'R', 'richese'), advanced, ['choam']); + for (const faction of factions.slice(1, seats)) joinGame(lobby, newPlayer(`authenticated-${faction}`, faction, faction)); + const setup = initialize({ initial: lobby, tech: seats >= 3, strongholds: advanced }); + const done = completeSetup(setup); + assert.equal(done.players.length, seats); + assert.equal(done.leaderSkills!.assignments.length, seats); + assert.deepEqual(done.players.map(p => p.id), lobby.players.map(p => p.id)); + assert.equal(done.status, 'playing'); + custody(done); +}); + +for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} nested signed two-marker Cunning keeps one physical invoice through native responses/Guild and private reserve caps`, () => { + const f = fixture({ advanced, tech: true, strongholds: advanced }), ending = close(f), before = ending.game; + const karama = acquiredCard(f, f.fremen, c => c.effect === 'karama'); + assert.ok(player(before, f.fremen).hand.some(c => c.id === karama.id)); + const own = player(before, f.richese), previous = own.noField!.tokens.find(t => t.value === 0)!; + const request = pairAction(before, f.richese); + reject(before, f.richese, { ...request, noField: previous.id }); + reject(before, f.richese, { ...request, revealedToken: request.noField }); + reject(before, f.richese, { ...request, nexus: 'stale-own-pair' }); + reject(before, f.richese, { ...request, territory: 'treachery-card-stash', sector: 0 }); + reject(before, f.richese, { ...request, sector: 1 }); + reject(before, f.richese, { ...request, amount: 8 }); + reject(before, f.richese, { ...request, smuggler: true }); + for (const cap of [2, 20]) { + const capped = reload(before), p = player(capped, f.richese); + placePairedSkillsForce(capped, f.richese, 'polar_sink:0', p.reserves - cap, f.staging); + const pending = applyAction(capped, f.richese, pairAction(capped, f.richese)); + assert.equal(pending.response?.kind, 'richeseNoField'); + assert.equal(pending.pendingShipment!.cost, 1); + assert.equal(player(pending, f.richese).spice, p.spice); + assert.equal(player(pending, f.richese).reserves, cap); + const corrupted = reload(pending), holder = player(corrupted, f.fremen); + corrupted.pendingShipment!.cost = 0; + reject(corrupted, holder.id, { type: 'passResponse' }); + const retargeted = reload(pending); + retargeted.pendingShipment!.territory = 'arrakeen'; + reject(retargeted, holder.id, { type: 'passResponse' }); + const guild = advance(pending, g => g.decision?.kind === 'guildShipment' || clean(g)); + if (advanced) { + assert.equal(guild.decision?.player, f.guild); + assert.equal(player(guild, f.richese).spice, p.spice); + reject(guild, f.choam, { type: 'decision', allow: true }); + reject(guild, f.guild, { type: 'decision', allow: false }); + } + const shipped = settle(guild); + assert.equal(player(shipped, f.richese).spice, p.spice - 1); + assert.equal(player(shipped, f.guild).spice, player(capped, f.guild).spice + 1); + assert.equal(player(shipped, f.richese).reserves, cap - Math.min(5, cap)); + assert.equal(player(shipped, f.richese).forces['shrine:0'], Math.min(5, cap)); + assert.equal(player(shipped, f.richese).noField!.deployed!.tokenId, request.noField); + assert.equal(player(shipped, f.richese).noField!.lastShipped, request.noField); + assert.equal(shipped.nexusCards!.cards!.hands[f.richese], null); + assert.deepEqual(player(shipped, f.richese).noField!.tokens, own.noField!.tokens); + reject(shipped, f.richese, request); + assert.equal(player(settle(shipped), f.guild).spice, player(shipped, f.guild).spice); + custody(shipped); + } + reject(before, f.richese, { ...pairAction(before, f.richese), smuggler: true }); +}); + +void test('actual original Karama holder cancels a nested pair without a fee, counter materialization or ordinary substitute entitlement', () => { + const f = fixture({ advanced: true, tech: true }), before = close(f).game; + const card = acquiredCard(f, f.fremen, c => c.effect === 'karama'), holder = player(before, f.fremen); + assert.ok(holder.hand.some(c => c.id === card.id)); + const request = pairAction(before, f.richese), pending = applyAction(reload(before), f.richese, request); + assert.equal(pending.response?.kind, 'richeseNoField'); + const stopped = settle(applyAction(pending, holder.id, { type: 'card', mode: 'cancel', card: card.id })); + assert.equal(stopped.nexusRicheseCunningLast!.stage, 'stopped'); + assert.equal(stopped.nexusCards!.cards!.hands[f.richese], null); + assert.equal(stopped.discard.filter(c => c.id === card.id).length, 1); + assert.equal(player(stopped, holder.id).hand.some(c => c.id === card.id), false); + for (const p of before.players) { + assert.equal(player(stopped, p.id).spice, p.spice); + assert.deepEqual(player(stopped, p.id).forces, p.forces); + assert.equal(player(stopped, p.id).reserves, p.reserves); + } + assert.equal(player(stopped, f.richese).noField!.lastShipped, player(before, f.richese).noField!.lastShipped); + reject(stopped, f.richese, request); + reject(stopped, f.richese, { type: 'ship', amount: 6, territory: 'shrine', sector: 0, smuggler: true }); + custody(stopped); +}); + +void test('all four minimal policies pass the actual Karama-holder response then consume the native two-marker invoice once', () => { + const f = fixture({ advanced: true, tech: true, strongholds: true }), before = close(f).game; + const card = acquiredCard(f, f.fremen, c => c.effect === 'karama'); + const holder = player(before, f.fremen), request = pairAction(before, f.richese); + assert.ok(holder.hand.some(c => c.id === card.id)); + for (const difficulty of DIFFICULTIES) { + const pending = applyAction(reload(before), f.richese, request), view = viewGame(pending, holder.id); + assert.equal(pending.response?.kind, 'richeseNoField'); + view.players.find(p => p.id === holder.id)!.bot = difficulty; + const action = botActions(view).find(a => a.type === 'passResponse'); + assert.ok(action, `${difficulty} must act as the actual eligible Karama holder.`); + const shipped = settle(applyAction(pending, holder.id, action)); + assert.equal(player(shipped, f.richese).forces['shrine:0'], 5); + assert.equal(player(shipped, f.richese).spice, player(before, f.richese).spice - 1); + assert.equal(player(shipped, f.guild).spice, player(before, f.guild).spice + 1); + assert.equal(shipped.nexusRicheseCunningLast!.stage, 'shipped'); + assert.equal(shipped.nexusCards!.cards!.hands[f.richese], null); + assert.deepEqual(player(shipped, holder.id).hand, player(before, holder.id).hand); + custody(shipped); + } +}); + +for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} original CHOAM Stash draws its one genuine next card without changing its five-card capacity or training`, () => { + const f = fixture({ advanced, kind: 'stash' }), before = f.collection; + const hand = player(before, f.choam).hand.map(c => c.id), drawn = before.deck[0]; assert.ok(drawn); + assert.equal(player(f.game, f.choam).hand.length, hand.length + 1); + assert.deepEqual(player(f.game, f.choam).hand.map(c => c.id), [...hand, drawn.id]); + assert.equal(f.game.deck.length, before.deck.length - 1); + assert.equal(player(f.game, f.choam).spice, player(before, f.choam).spice); + assert.equal(f.game.discoveries!.tokens.find(t => t.id === f.token)!.status, 'removed'); + assert.deepEqual(f.game.leaderSkills!.assignments, before.leaderSkills!.assignments); + reject(f.game, f.choam, { type: 'discovery', token: f.token, reveal: true }); + custody(f.game); +}); + +for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} own CHOAM Cunning consumes actual auction-acquired Special fuel, separate from the trained Smuggler invoice`, () => { + const f = fixture({ advanced, tech: true, strongholds: advanced }), ending = close(f); + const before = advance(ending.movementStart, g => g.phase === 5 && g.active === f.choam && clean(g)); + const fuel = acquiredCard(f, f.choam, c => c.kind === 'special' && c.effect !== 'karama'); + assert.ok(player(before, f.choam).hand.some(c => c.id === fuel.id)); + const offer = viewGame(before, f.choam).choamWorthless!.plays.find(p => p.source === 'nexus' && p.card.id === fuel.id && p.effect === 'kulon'); + assert.ok(offer && !offer.blocked && offer.source === 'nexus'); + const request: Action = { type: 'card', mode: 'choam', card: fuel.id, effect: 'kulon', nexus: offer.event }; + reject(before, f.choam, { ...request, nexus: 'stale-fuel' }); + const declared = applyAction(reload(before), f.choam, request); + if (declared.response) assert.equal(declared.response.kind, 'choamWorthless'); + assert.equal(declared.nexusCards!.cards!.hands[f.choam], null); + if (declared.response) assert.equal(player(declared, f.choam).hand.some(c => c.id === fuel.id), true); + const fueled = settle(declared); + assert.equal(fueled.discard.filter(c => c.id === fuel.id).length, 1); + assert.equal(player(fueled, f.choam).hand.some(c => c.id === fuel.id), false); + assert.equal(fueled.choamMovement!.bonus, 1); + reject(fueled, f.choam, request); + const shipment = invoice(fueled), paid = settle(applyAction(reload(fueled), f.choam, shipment.action)); + assert.equal(player(paid, f.choam).forces[shipment.key], 6); + assert.equal(player(paid, f.choam).reserves, player(fueled, f.choam).reserves - 6); + assert.equal(player(paid, f.choam).spice, player(fueled, f.choam).spice - 5); + assert.equal(player(paid, f.guild).spice, player(fueled, f.guild).spice + 5); + assert.equal(paid.discard.filter(c => c.id === fuel.id).length, 1); + assert.deepEqual(paid.leaderSkills!.assignments, before.leaderSkills!.assignments); + custody(paid); +}); + +void test('all four minimal card policies spend actual physical fuel for Kulon before a separate ordinary skill move', () => { + const f = fixture({ advanced: true, choamSkill: 'planetologist', tech: true, strongholds: true }), ending = close(f); + const before = advance(ending.movementStart, g => g.phase === 5 && g.active === f.choam && clean(g)); + const route = stagePairedSkillsPlanetologistRoute(before, f.choam, f.staging); + reject(before, f.choam, route.action); + for (const difficulty of DIFFICULTIES) { + const view = viewGame(before, f.choam); view.players.find(p => p.id === f.choam)!.bot = difficulty; + const action = botActions(view).find(a => a.type === 'card' && a.mode === 'choam' && a.effect === 'kulon'); + assert.ok(action); + const fuel = player(before, f.choam).hand.find(c => c.id === action.card); assert.ok(fuel); + const used = settle(applyAction(reload(before), f.choam, action)); + assert.equal(used.discard.filter(c => c.id === fuel.id).length, 1); + assert.equal(player(used, f.choam).hand.some(c => c.id === fuel.id), false); + const moved = settle(applyAction(used, f.choam, route.action)); + assert.equal(player(moved, f.choam).forces[route.from] ?? 0, 0); + assert.equal(player(moved, f.choam).forces[route.to], 1); + assert.equal(player(moved, f.choam).moved, 1); + assert.equal(moved.discard.filter(c => c.id === fuel.id).length, 1); + assert.deepEqual(moved.leaderSkills!.assignments, before.leaderSkills!.assignments); + custody(moved); + } +}); + +void test('all four eligible Suk decisions follow voluntary nested pair reveal and settled physical casualties before optional Tech transfer', () => { + const f = fixture({ advanced: true, tech: true, strongholds: true }), before = close(f).game; + const shipped = settle(applyAction(reload(before), f.richese, pairAction(before, f.richese))); + const battle = prepareBattle(f, shipped); + let game = battle.game; + for (const plan of battle.plans) game = settle(step(game, plan)); + const revealed = reload(game), loserTokens = ownedTech(game.techTokens, f.guild); + assert.equal(player(game, f.richese).forces[battle.key], 8); + game = advance(game, g => g.decision?.kind === 'sukRescue'); + assert.equal(game.decision?.player, f.richese); + if (game.decision?.kind !== 'sukRescue') throw Error('The genuinely trained battle must reach its actual rescue decision.'); + const pending = game, decision = game.decision; + reject(pending, f.guild, { type: 'decision', event: decision.event, choice: 0 }); + reject(pending, f.richese, { type: 'decision', event: 'stale-rescue', choice: 0 }); + for (const difficulty of DIFFICULTIES) { + const view = viewGame(pending, f.richese); view.players.find(p => p.id === f.richese)!.bot = difficulty; + const action = botActions(view).find(a => a.type === 'decision' && typeof a.choice === 'number'); assert.ok(action && typeof action.choice === 'number'); + const rescue = decision.options[action.choice]; assert.ok(rescue); + const done = finishBattle(applyAction(reload(pending), f.richese, action)); + const kept = rescue.kept?.kind === 'normal' ? 1 : 0; + assert.equal(player(done, f.richese).forces[battle.key], 6 + kept); + assert.equal(player(done, f.richese).reserves, 12 + rescue.normal - kept); + assert.equal(player(done, f.richese).tanks, 2 - rescue.normal); + assert.equal(player(done, f.guild).tanks, player(revealed, f.guild).tanks + 3); + assert.equal(done.lastBattleContext!.winner, f.richese); + assert.equal(player(done, f.richese).noField!.deployed, null); + assert.deepEqual(done.leaderSkills!.assignments, before.leaderSkills!.assignments); + for (const token of loserTokens) assert.equal(done.techTokens![token].owner, f.richese); + custody(done); + } +}); + +void test('held Arrakeen support affects only its proper payer leg, not an unrelated nested Shrine entitlement', () => { + const f = fixture({ advanced: true, tech: true, strongholds: true, arrakeenOwner: 'richese' }), ending = close(f); + assert.equal(ending.game.strongholdCards!.owners.arrakeen, f.richese); + const nested = settle(applyAction(reload(ending.game), f.richese, pairAction(ending.game, f.richese))); + const battle = prepareBattle(f, nested); + let game = battle.game; + for (const plan of battle.plans) game = settle(step(game, plan)); + const wallet = player(game, f.richese).spice; + const committed = advance(game, g => g.decision?.kind === 'sukRescue'); + assert.equal(player(committed, f.richese).spice, wallet - 2, 'Arrakeen held card cannot fund support at another source.'); + assert.equal(committed.strongholdCards!.owners.arrakeen, f.richese); + const done = finishBattle(committed); + assert.equal(done.lastBattleContext!.winner, f.richese); + assert.equal(player(done, f.richese).forces['shrine:0'], 7); + custody(done); +}); + +void test('actual held Arrakeen bank support changes its payer debit without increasing trained dial or rescue', () => { + const f = fixture({ advanced: true, tech: true, strongholds: true, arrakeenOwner: 'richese' }), before = close(f).game; + const key = `arrakeen:${territory('arrakeen').sectors[0]}`, own = player(before, f.richese); + assert.equal(before.strongholdCards!.owners.arrakeen, f.richese); + own.reserves += own.forces[key]; delete own.forces[key]; + f.staging.push('Conserve the original Arrakeen claiming counter back to native reserves after its real END Mentat claim; retain the actually held card, never assign custody.'); + const shipped = settle(applyAction(reload(before), f.richese, pairAction(before, f.richese, 'arrakeen'))); + const battle = prepareBattle(f, shipped); + let game = battle.game; + reject(game, f.richese, { ...battle.plans[0].action, dial: 9, support: 9 }); + for (const plan of battle.plans) game = settle(step(game, plan)); + const wallet = player(game, f.richese).spice; + const committed = advance(game, g => g.decision?.kind === 'sukRescue'); + assert.equal(player(committed, f.richese).spice, wallet, 'Held Arrakeen funds exactly the original declared support leg.'); + assert.equal(committed.decision?.player, f.richese); + const done = finishBattle(committed); + assert.equal(done.lastBattleContext!.winner, f.richese); + assert.equal(player(done, f.richese).forces[key], 7); + assert.equal(player(done, f.richese).tanks, 0); + assert.equal(player(done, f.guild).tanks, 3); + assert.equal(done.strongholdCards!.owners.arrakeen, f.richese); + custody(done); +}); diff --git a/tests/discovery-standalone-nexus-e3-skills-runtime.test.ts b/tests/discovery-standalone-nexus-e3-skills-runtime.test.ts new file mode 100644 index 00000000..ad87b78f --- /dev/null +++ b/tests/discovery-standalone-nexus-e3-skills-runtime.test.ts @@ -0,0 +1,329 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; +import type { FactionId } from '../game/catalog'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { DUKE_VIDAL_ID } from '../game/duke-vidal'; +import { ownedTech } from '../game/tech-tokens'; +import { + initializeDiscoveryStandaloneNexusE3Skills as initialize, + completeDiscoveryStandaloneNexusE3SkillsSetup as complete, + createDiscoveryStandaloneNexusE3SkillsFixture as fixture, + closeDiscoveryStandaloneNexusE3Skills as close, + enterDiscoveryStandaloneNexusE3Skills as enter, + cunningDiscoveryStandaloneNexusE3Skills as cunning, + openDiscoveryStandaloneNexusE3SkillsBattle as open, + revealDiscoveryStandaloneNexusE3SkillsBattle as reveal, + settleDiscoveryStandaloneNexusE3SkillsBattle as boundary, + finishDiscoveryStandaloneNexusE3SkillsBattle as finish, + advanceDiscoveryStandaloneNexusE3Skills as advance, + stepDiscoveryStandaloneNexusE3Skills as step, + discoveryStandaloneNexusE3SkillsPlayer as player, + discoveryStandaloneNexusE3SkillsPolicy as policy, + discoveryStandaloneNexusE3SkillsClean as clean, + assertDiscoveryStandaloneNexusE3SkillsCustody as custody, + type DiscoveryStandaloneNexusE3SkillsOptions, +} from './fixture-discovery-standalone-nexus-e3-skills'; + +function reject(game: Game, actor: string, action: Action): void { + const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before); +} +/** Consume actual physical loss/rescue choices; do not hide the later owner choice. */ +function aftermath(state: Game, callers: readonly string[] = []): Game { + let next = boundary(state, callers); + for (let count = 0; count < 20 && (next.kind === 'losses' || next.kind === 'rescue'); count++) { + const d = next.game.decision!; + if (d.kind === 'battleLosses') next = boundary(step(next.game, { actor: d.player, action: { type: 'decision', choice: 0 } }), callers); + else if (d.kind === 'sukRescue') { + const max = Math.max(...d.options.map(o => o.normal + o.elite)); + const choice = d.options.findIndex(o => o.normal + o.elite === max); + next = boundary(step(next.game, { actor: d.player, action: { type: 'decision', event: d.event, choice } }), callers); + } + } + return next.game; +} + +void test('original one-native Basic/Advanced 2–6 setup trains eligible own discs and preserves supplied original hands and offers', () => { + for (const native of ['ecaz', 'moritani'] as const) for (const rules of ['basic', 'advanced'] as const) + for (const count of [2, 3, 4, 5, 6]) { + const factions: FactionId[] = [native, 'guild', 'emperor', 'fremen', 'beneGesserit', rules === 'basic' ? 'harkonnen' : 'atreides']; + const options: DiscoveryStandaloneNexusE3SkillsOptions = { native, rules, factions: factions.slice(0, count), + seatIds: factions.slice(0, count).map((f, n) => `${native}-${rules}-${count}-${n}-${f}`), + tech: count >= 3, strongholds: rules === 'advanced' }; + if (native === 'ecaz' && rules === 'basic' && count % 2) { assert.throws(() => initialize(options)); continue; } + const offered = advance(initialize(options), g => g.setupStage === 'leaderSkills'); + const owner = offered.players.find(p => p.faction === native)!.id; + const original = structuredClone(offered), continued = initialize({ initial: offered }); + assert.deepEqual(continued, original); + const trained = complete(continued, { initial: offered }); + assert.deepEqual(trained.players.map(p => p.id), original.players.map(p => p.id)); + for (const p of original.players) assert.deepEqual(player(trained, p.id).hand, p.hand); + assert.ok(trained.leaderSkills!.assignments.every(a => original.leaderSkills!.offers[a.owner].cards.includes(a.skill) && + player(original, a.owner).leaders.some(l => l.id === a.leader))); + const assignment = trained.leaderSkills!.assignments.find(a => a.owner === owner)!; + assert.notEqual(assignment.leader, DUKE_VIDAL_ID); + if (rules === 'basic') assert.equal(trained.moritaniAssassinate, undefined); + if (count === 2) { + const f = fixture({ initial: offered }); + assert.equal(viewGame(f.game, owner).discoveryEntry!.owner, owner); + if (rules === 'advanced') { + assert.equal(f.firstMentat.before.strongholdCards!.owners[f.claim], null); + assert.equal(f.game.strongholdCards!.owners[f.claim], owner); + } + assert.throws(() => close(f), /two other real alliance seats/); + } + custody(trained); + } +}); + +void test('source guards reject Advanced Harkonnen, paired/mixed E3, variants, shared Duke and foreign/captured training without consuming offers', () => { + for (const native of ['ecaz', 'moritani'] as const) { + for (const other of ['harkonnen', native === 'ecaz' ? 'moritani' : 'ecaz', 'ixians', 'choam', 'richese', 'tleilaxu'] as FactionId[]) + assert.throws(() => initialize({ native, factions: [native, 'guild', other] })); + assert.throws(() => initialize({ native, rules: 'basic', strongholds: true })); + const offered = initialize({ native }), owner = offered.players.find(p => p.faction === native)!.id; + const own = viewGame(offered, owner).leaderSkills!, skill = own.offer!.cards.find(s => !own.unavailableSkills?.[s])!; + const foreign = offered.players.find(p => p.id !== owner)!.leaders[0].id; + for (const leader of [DUKE_VIDAL_ID, foreign]) reject(offered, owner, + { type: 'leaderSkill', event: own.offer!.event, skill, leader }); + const captured = structuredClone(offered), disc = player(captured, owner).leaders.find(l => l.id === own.eligibleLeaders[0].id)!; + // Rejected counterfactual only: no captured state is accepted as played history. + disc.capturedBy = offered.players.find(p => p.id !== owner)!.id; + reject(captured, owner, { type: 'leaderSkill', event: own.offer!.event, skill, leader: disc.id }); + assert.throws(() => initialize({ initial: { ...offered, ecazTreachery: true } })); + assert.throws(() => initialize({ initial: { ...offered, homeworlds: {} as NonNullable } })); + custody(offered); + } +}); + +void test('human lobby/setup actors and first hands survive the actual Discovery reveal and pre-Storm free entry, not a redeal or a paid shipment', () => { + for (const native of ['ecaz', 'moritani'] as const) { + const lobby = createGame(`HUMAN-${native}`, newPlayer(`human-${native}`, native, native), true, ['ecaz']); + joinGame(lobby, newPlayer('human-guild', 'Guild', 'guild')); joinGame(lobby, newPlayer('human-fremen', 'Fremen', 'fremen')); + const original = structuredClone(lobby), offered = initialize({ initial: lobby }), f = fixture({ initial: offered }); + assert.deepEqual(lobby, original); assert.deepEqual(f.setup, offered); + assert.equal(f.owner, `human-${native}`); assert.equal(f.opponent, 'human-guild'); + for (const p of offered.players) assert.deepEqual(player(f.game, p.id).hand, p.hand); + const own = viewGame(f.game, f.owner), source = player(f.game, f.owner).forces[f.source]; + assert.equal(own.discoveryEntry!.owner, f.owner); + assert.equal(f.firstMentat.before.turn, 1); assert.equal(f.firstMentat.before.phase, 8); + assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 0); + reject(f.game, f.opponent, { type: 'decision', event: own.discoveryEntry!.event, accept: false }); + reject(f.firstMentat.before, f.owner, { type: 'nexusCardChoice', turn: 1, card: null, choice: 'draw', ownRedraws: 0 }); + const entered = enter(f), funds = player(f.game, f.owner).spice; + assert.equal(player(entered, f.owner).forces['cistern:0'], 3); + assert.equal(player(entered, f.owner).forces[f.source] ?? 0, source - 3); + assert.equal(player(entered, f.owner).spice, funds); + assert.equal(player(entered, f.owner).shipped, player(f.game, f.owner).shipped); + assert.deepEqual(entered.leaderSkills, f.game.leaderSkills); custody(entered); + } +}); + +void test('Maker losses, all votes, typed reserve ride, settled alliances and both Advanced piles precede the only native closing draw', () => { + for (const native of ['ecaz', 'moritani'] as const) { + const f = fixture({ native, tech: true, strongholds: true }), closing = close(f); + assert.equal(player(closing.vote, f.owner).tanks, player(closing.entered, f.owner).tanks + 6); + assert.equal(player(closing.vote, f.owner).forces[f.wormSource] ?? 0, 0); + assert.equal(player(closing.vote, f.owner).forces['cistern:0'], 3); + assert.equal(closing.vote.nexusCards!.cards!.hands[f.owner], null); + reject(closing.vote, f.owner, closing.draw.step.action); + const ride = closing.ride; assert.ok(ride && f.fremen); + const rider = player(ride.before, f.fremen); + assert.equal(player(ride.after, f.fremen).reserves, rider.reserves - 2); + assert.equal(player(ride.after, f.fremen).elites!.reserves, rider.elites!.reserves - 1); + assert.equal(player(ride.after, f.fremen).forces['polar_sink:0'], 2); + assert.equal(player(ride.after, f.fremen).elites!.forces['polar_sink:0'], 1); + assert.equal(player(ride.after, f.fremen).spice, rider.spice); + assert.equal(closing.draw.before.phase, 1); assert.equal(closing.draw.before.spiceWindow, null); + assert.equal(closing.draw.before.spiceResolution, null); assert.equal(closing.draw.before.nexus, false); + assert.equal(player(closing.alliance, f.owner).ally, null); + assert.equal(player(closing.alliance, f.opponent).ally, f.fremen); + assert.equal(closing.draw.after.nexusCards!.cards!.hands[f.owner], native); + assert.equal(closing.draw.after.nexusCards!.cards!.deck.includes(native), false); + reject(closing.draw.after, f.owner, closing.draw.step.action); + assert.equal(f.firstMentat.before.strongholdCards!.owners[f.claim], null); + assert.equal(f.firstMentat.after.strongholdCards!.owners[f.claim], f.owner); + assert.equal(ownedTech(f.afterFirstStorm.techTokens, f.owner).length, 1); + custody(closing.game); + } +}); + +void test('borrowed Emperor free-return remains source guarded for either native even with real Maker casualties and an actually drawn card', () => { + for (const native of ['ecaz', 'moritani'] as const) { + const f = fixture({ native }), closing = close(f, 'emperor'); + const revival = advance(closing.game, g => g.phase === 4 && clean(g)); + assert.equal(player(revival, f.owner).tanks, 6); + const offer = viewGame(revival, f.owner).nexusEmperorSecretAlly; assert.ok(offer && offer.revival.blocked); + const action: Action = { type: 'nexusEmperorRevive', event: offer.event, elite: 0 }; + reject(revival, f.owner, action); + assert.equal(revival.nexusCards!.cards!.hands[f.owner], 'emperor'); + assert.equal(player(revival, f.owner).freeForcesRevived, 0); + custody(revival); + } +}); + +void test('quiet native Ecaz Cunning acquires the living unclaimed Duke separately from training, and rejects exceptional custody or an already-open battle', () => { + const f = fixture({ native: 'ecaz' }), closing = close(f), acquired = cunning(f, closing.game); + assert.equal(acquired.before.status, 'playing'); + assert.equal(acquired.before.turn, closing.game.turn); + assert.equal(acquired.before.phase, 6); + assert.equal(acquired.before.battle, null); + assert.ok(viewGame(acquired.before, f.owner).battleChoices.length > 0, + 'Physical opponents keep the original Battle chooser open before quiet Cunning.'); + assert.equal(acquired.before.dukeVidal!.controller, null); + assert.equal(acquired.after.dukeVidal!.controller, f.owner); + assert.equal(acquired.after.dukeVidal!.leader.dead, false); + assert.equal(acquired.after.nexusCards!.cards!.hands[f.owner], null); + assert.deepEqual(acquired.after.leaderSkills, acquired.before.leaderSkills); + assert.ok(acquired.after.leaderSkills!.assignments.every(a => a.leader !== DUKE_VIDAL_ID)); + for (const property of ['dead', 'capturedBy', 'gholaBy', 'usedAt'] as const) { + const input = structuredClone(acquired.before); + if (property === 'dead') input.dukeVidal!.leader.dead = true; + else if (property === 'usedAt') input.dukeVidal!.leader.usedAt = 'cistern'; + else input.dukeVidal!.leader[property] = f.opponent; + reject(input, f.owner, acquired.step.action); + } + const battle = open(f, closing.game); + reject(battle.game, f.owner, acquired.step.action); + const revealed = reveal(battle), cleanup = aftermath(revealed); + assert.equal(cleanup.decision?.kind, 'battleCards'); assert.equal(cleanup.decision!.player, f.owner); + assert.equal(player(cleanup, f.owner).forces[battle.location], 5); + assert.equal(player(cleanup, f.opponent).forces[battle.location] ?? 0, 0); + assert.equal(player(cleanup, f.opponent).tanks, player(revealed, f.opponent).tanks + 6); + assert.equal(cleanup.dukeVidal!.controller, f.owner, 'The actual native trained disc was used; the separate Duke was not set aside.'); + const done = finish(step(cleanup, { actor: f.owner, action: { type: 'decision', discard: [] } })); + assert.equal(player(done, f.owner).hand.some(c => c.id === battle.card), true); + assert.equal(player(done, f.owner).leaders.find(l => l.id === f.leader)!.usedAt, 'cistern'); + custody(done); +}); + +void test('mandatory selected-lead Occupy stays a real battle with owner-labelled commitments, native skill custody, payments, cleanup and Tech', () => { + for (const lead of ['native', 'ally'] as const) { + const f = fixture({ native: 'ecaz', factions: ['ecaz', 'guild', 'emperor'], tech: true, strongholds: true }), closing = close(f); + const acquired = cunning(f, closing.game), battle = open(f, acquired.after, { occupy: true, lead }); + const choice = battle.leadChoice; assert.ok(choice); + assert.equal(choice.before.status, 'playing'); + assert.equal(choice.before.turn, acquired.after.turn + 1); + assert.equal(choice.before.decision!.kind, 'ecazBattleLead'); assert.equal(choice.before.decision!.player, f.owner); + reject(choice.before, f.opponent, choice.step.action); + reject(choice.before, f.owner, { ...choice.step.action, lead: f.opponent }); + assert.equal(battle.game.battle!.ecazOccupy!.lead, battle.actor); + assert.equal(battle.game.battle!.plans[battle.actor], undefined); + assert.notEqual(battle.leader, DUKE_VIDAL_ID); + if (lead === 'ally') reject(battle.game, battle.actor, { ...battle.plans[0].action, leader: f.leader }); + const assignments = structuredClone(battle.game.leaderSkills!.assignments), revealed = reveal(battle); + const cleanup = aftermath(revealed); + assert.equal(cleanup.decision?.kind, 'battleCards'); assert.equal(cleanup.decision!.player, battle.actor); + assert.equal(player(cleanup, f.owner).forces[battle.location], 1); + assert.equal(player(cleanup, f.owner).tanks, player(revealed, f.owner).tanks + 2); + const ally = player(revealed, f.owner).ally!; + assert.equal(player(cleanup, ally).forces[battle.location], 5); + assert.equal(player(cleanup, ally).tanks, player(revealed, ally).tanks + 1); + assert.equal(player(cleanup, f.opponent).tanks, player(revealed, f.opponent).tanks + 6); + assert.equal(player(cleanup, battle.actor).spice, player(revealed, battle.actor).spice - 1); + const companion = battle.actor === f.owner ? ally : f.owner; + assert.equal(player(cleanup, companion).spice, player(revealed, companion).spice); + assert.equal(player(cleanup, f.opponent).spice, player(revealed, f.opponent).spice - 1); + assert.deepEqual(cleanup.techTokens, revealed.techTokens, 'Actual winner card custody precedes optional winner Tech.'); + const done = finish(step(cleanup, { actor: battle.actor, action: { type: 'decision', discard: [battle.card] } })); + assert.equal(done.discard.filter(c => c.id === battle.card).length, 1); + assert.equal(player(done, battle.actor).hand.some(c => c.id === battle.card), false); + assert.deepEqual(done.leaderSkills!.assignments.map(a => ({ owner: a.owner, leader: a.leader, skill: a.skill })), + assignments.map(a => ({ owner: a.owner, leader: a.leader, skill: a.skill }))); + for (const token of ownedTech(revealed.techTokens, f.opponent)) assert.equal(done.techTokens![token].owner, battle.actor); + assert.equal(done.strongholdCards!.owners.arrakeen, f.owner); + custody(done); + } +}); + +void test('Advanced Moritani after-loss assassination kills the actual unused trained disc, pays printed bounty, returns its skill and replaces only the private Traitor at actual Mentat', () => { + const f = fixture({ native: 'moritani', tech: true, strongholds: true }), closing = close(f), battle = open(f, closing.game); + assert.ok(f.target); + assert.ok(player(f.trained, f.owner).traitors.includes(f.target)); + assert.ok(f.actions.some(s => s.actor === f.owner && s.action.type === 'traitor' && s.action.leader === f.target), + 'The trained enemy disc is selected through the original pending Traitor offer.'); + const revealed = reveal(battle), pending = aftermath(revealed); + assert.equal(pending.decision?.kind, 'moritaniAssassinate'); assert.equal(pending.decision!.player, f.owner); + assert.equal(player(pending, f.owner).tanks, player(revealed, f.owner).tanks + 6); + const target = player(pending, f.opponent).leaders.find(l => l.id === f.target)!; + const originalSkill = pending.leaderSkills!.assignments.find(a => a.leader === target.id)!.skill; + assert.equal(target.dead, false); assert.equal(target.usedAt, undefined); + const decision = pending.decision; assert.ok(decision?.kind === 'moritaniAssassinate'); + const action: Action = { type: 'decision', event: decision.event, card: target.id }; + reject(pending, f.opponent, action); reject(pending, f.owner, { ...action, card: battle.enemyLeader }); + const reserve = [...pending.traitorReserve!], held = [...player(pending, f.owner).traitors]; + const quote = viewGame(pending, f.owner).moritaniAssassinate!.pending!.cards.find(c => c.card === target.id)!; + assert.equal(quote.bounty, 3); + const killed = step(pending, { actor: f.owner, action }); + assert.equal(player(killed, f.opponent).leaders.find(l => l.id === target.id)!.dead, true); + assert.equal(player(killed, f.opponent).leaders.find(l => l.id === target.id)!.deaths, target.deaths + 1); + assert.equal(player(killed, f.owner).spice, player(pending, f.owner).spice + 3); + assert.equal(killed.leaderSkills!.assignments.some(a => a.leader === target.id), false); + assert.equal(killed.leaderSkills!.deck.filter(s => s === originalSkill).length, 1); + assert.equal(killed.decision?.kind, 'battleCards'); assert.equal(killed.decision!.player, f.opponent); + assert.deepEqual(killed.techTokens, pending.techTokens); + let done = finish(step(killed, { actor: f.opponent, action: { type: 'decision', discard: [battle.enemyCard!] } })); + for (const card of [battle.card, battle.enemyCard!]) assert.equal(done.discard.filter(c => c.id === card).length, 1); + for (const token of ownedTech(revealed.techTokens, f.owner)) assert.equal(done.techTokens![token].owner, f.opponent); + done = advance(done, g => g.decision?.kind === 'moritaniPlacement'); + assert.deepEqual(done.traitorReserve, reserve.slice(1)); + assert.deepEqual(player(done, f.owner).traitors, [...held.filter(c => c !== target.id), reserve[0]]); + assert.equal('replacement' in viewGame(done, f.opponent).moritaniAssassinate!.history.at(-1)!, false); + assert.equal(done.leaderSkills!.offers[f.opponent], undefined); + reject(done, f.owner, action); + const placed = cunning(f, done); + assert.equal(placed.after.nexusCards!.cards!.hands[f.owner], null); + assert.equal(placed.after.moritaniTerror!.tokens.find(t => t.kind === 'robbery')!.location, 'red_chasm'); + assert.deepEqual(placed.after.leaderSkills, done.leaderSkills); + custody(placed.after); +}); + +void test('normal Traitor call forfeits Advanced assassination; Basic losses never grant its power, while original Cunning remains available', () => { + const advanced = fixture({ native: 'moritani' }), closing = close(advanced); + const called = open(advanced, closing.game, { normalCall: true }), revealed = reveal(called); + assert.ok(player(advanced.trained, advanced.owner).traitors.includes(called.enemyLeader)); + assert.equal(called.enemyLeader, advanced.target); + let done = aftermath(revealed, [advanced.owner]); + assert.notEqual(done.decision?.kind, 'moritaniAssassinate'); + done = finish(done); + assert.equal(player(done, advanced.opponent).leaders.find(l => l.id === called.enemyLeader)!.dead, true); + const held = [...player(done, advanced.owner).traitors], reserve = [...done.traitorReserve!]; + done = advance(done, g => g.decision?.kind === 'moritaniPlacement'); + assert.deepEqual(player(done, advanced.owner).traitors, held); assert.deepEqual(done.traitorReserve, reserve); + custody(done); + const basic = fixture({ native: 'moritani', rules: 'basic', tech: true }), basicClose = close(basic); + const battle = open(basic, basicClose.game), basicReveal = reveal(battle), loss = aftermath(basicReveal); + assert.equal(loss.moritaniAssassinate, undefined); + assert.equal(player(loss, basic.owner).spice, player(basicReveal, basic.owner).spice); + reject(loss, basic.owner, { type: 'decision', event: 'not-earned', card: basic.target }); + assert.equal(player(loss, basic.opponent).leaders.find(l => l.id === basic.target)!.dead, false); + const finished = finish(loss), placed = cunning(basic, finished); + assert.equal(placed.after.nexusCards!.cards!.hands[basic.owner], null); + assert.equal(placed.after.moritaniTerror!.tokens.find(t => t.kind === 'robbery')!.location, 'red_chasm'); + assert.equal(placed.after.moritaniAssassinate, undefined); custody(placed.after); +}); + +void test('all four minimal policies act for the eligible native Cunning/assassination and cleanup owner with physical consumer outcomes', () => { + const ecaz = fixture({ native: 'ecaz' }), ecazClosing = close(ecaz), ecazCunning = cunning(ecaz, ecazClosing.game); + const moritani = fixture({ native: 'moritani', tech: true }), moritaniClosing = close(moritani); + const battle = open(moritani, moritaniClosing.game), pending = aftermath(reveal(battle)); + assert.equal(pending.decision?.kind, 'moritaniAssassinate'); + for (const difficulty of DIFFICULTIES) { + const ecazAction = policy(ecazCunning.before, ecaz.owner, difficulty)[0]; assert.ok(ecazAction); + const acquired = step(structuredClone(ecazCunning.before), { actor: ecaz.owner, action: ecazAction }); + assert.equal(acquired.dukeVidal!.controller, ecaz.owner); assert.equal(acquired.nexusCards!.cards!.hands[ecaz.owner], null); + assert.deepEqual(acquired.leaderSkills, ecazCunning.before.leaderSkills); + const action = policy(pending, pending.decision!.player, difficulty)[0]; assert.ok(action); + let game = step(structuredClone(pending), { actor: pending.decision!.player, action }); + assert.equal(player(game, moritani.opponent).leaders.find(l => l.id === moritani.target)!.dead, true); + assert.equal(player(game, moritani.owner).spice, player(pending, moritani.owner).spice + 3); + assert.equal(game.decision?.kind, 'battleCards'); + const cleanupActor = game.decision!.player, cleanup = policy(game, cleanupActor, difficulty)[0]; assert.ok(cleanup); + game = finish(step(game, { actor: cleanupActor, action: cleanup })); + assert.equal(game.battle, null); assert.equal(player(game, moritani.owner).hand.some(c => c.id === battle.card), false); + game = advance(game, g => g.decision?.kind === 'moritaniPlacement'); + assert.equal(player(game, moritani.owner).traitors.includes(moritani.target!), false); + assert.equal(game.leaderSkills!.assignments.some(a => a.leader === moritani.target), false); + custody(game); + } +}); diff --git a/tests/fixture-discovery-paired-nexus-e1-skills.ts b/tests/fixture-discovery-paired-nexus-e1-skills.ts new file mode 100644 index 00000000..5cd746ee --- /dev/null +++ b/tests/fixture-discovery-paired-nexus-e1-skills.ts @@ -0,0 +1,362 @@ +import assert from 'node:assert/strict'; +import { + applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, + viewGame, type Action, type Game, +} from '../game/engine'; +import { gameDistance, gameTerritories, MOBILE_LOCATION, splitLocation, territory } from '../game/board'; +import { isAuditorLeader } from '../game/cards'; +import { DISCOVERY_CARD_PLACEMENTS } from '../game/discoveries'; +import { greatMakerRideAction } from '../game/great-maker-options'; +import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards'; +import { pairedNexusLeaderSkillsProfile } from '../game/leader-skill-profile'; +import { traitorDeck } from '../game/traitors'; +import { nextPairedIxNexusSkillsModulesStep } from './fixture-paired-ix-nexus-skills-modules'; +import { + pairedDiscoveryNexusClean as clean, pairedDiscoveryNexusPlayer, + placePairedDiscoveryNexus, reservePairedDiscoveryNexusBoard, + type PairedDiscoveryNexusClosing, type PairedDiscoveryNexusFixture, + type PairedDiscoveryNexusTransition, +} from './fixture-discovery-paired-nexus'; +import { withClassicDiscoveryNexusLottery, withClassicDiscoveryNexusToken } from './fixture-discovery-classic-nexus'; + +export type DiscoveryPairedNexusE1SkillsStep = { actor: string; action: Action }; +export type DiscoveryPairedNexusE1SkillsOptions = { + /** Authenticated original lobby or unassigned original turn-one setup. */ + initial?: Game; + advanced?: boolean; + tech?: boolean; + strongholds?: boolean; + /** An explicit discipline must belong to the actual original offer. */ + skill?: LeaderSkillId; +}; +export type DiscoveryPairedNexusE1SkillsSetup = { + initial: Game; setup: Game; offered: Game; afterSetup: Game; game: Game; + owner: string; native: string; guild: string; fremen: string; + trainer: string; skill: LeaderSkillId; + actions: DiscoveryPairedNexusE1SkillsStep[]; staging: string[]; +}; +export type DiscoveryPairedNexusE1SkillsEntry = PairedDiscoveryNexusFixture & { + offered: Game; trainer: string; skill: LeaderSkillId; +}; +export type DiscoveryPairedNexusE1SkillsBattle = { + entry: DiscoveryPairedNexusE1SkillsEntry; closing: PairedDiscoveryNexusClosing; + beforeBattle: Game; battleStep: DiscoveryPairedNexusE1SkillsStep; game: Game; + owner: string; opponent: string; trainer: string; skill: LeaderSkillId; + enemy: string; location: 'shrine:0'; replacementSource: string; + /** Only original, actually dealt cards are eligible; none is injected. */ + weapon: string | null; defense: string | null; + plans: DiscoveryPairedNexusE1SkillsStep[]; + actions: DiscoveryPairedNexusE1SkillsStep[]; staging: string[]; +}; +export type DiscoveryPairedNexusE1SkillsBoundary = + 'losses' | 'rescue' | 'substitution' | 'cards' | 'tech' | 'faceDance' | 'complete'; +export const discoveryPairedNexusE1SkillsPlayer = pairedDiscoveryNexusPlayer; + +/** Original schedulers handle shared responses first. Returned controls never + * choose an owned free entry, vote, ride, Cunning, private plan or Face Dance. */ +export function nextDiscoveryPairedNexusE1SkillsStep(game: Game): DiscoveryPairedNexusE1SkillsStep | null { + if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) { + const d = game.decision; + 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: false } }; + if (d?.kind === 'discoveryDiscard') { + const card = pairedDiscoveryNexusPlayer(game, d.player).hand[0]; assert.ok(card); + return { actor: d.player, action: { type: 'decision', event: d.event, card: card.id } }; + } + if (d?.kind === 'faceDance' || d?.kind === 'strongholdCopy') return null; + } + return nextPairedIxNexusSkillsModulesStep(game); +} +export function stepDiscoveryPairedNexusE1Skills(game: Game, step: DiscoveryPairedNexusE1SkillsStep, + actions?: DiscoveryPairedNexusE1SkillsStep[]): Game { + actions?.push(structuredClone(step)); + return withClassicDiscoveryNexusLottery(() => applyAction(game, step.actor, step.action)); +} +export function advanceDiscoveryPairedNexusE1Skills(state: Game, until: (game: Game) => boolean, + actions?: DiscoveryPairedNexusE1SkillsStep[]): Game { + let game = structuredClone(state); + for (let n = 0; n < 2400; n++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished'); + const step = nextDiscoveryPairedNexusE1SkillsStep(game); + assert.ok(step, 'Original paired E1 needs its owned human plan, copy or matching Face Dance'); + game = stepDiscoveryPairedNexusE1Skills(game, step, actions); + } + throw Error('Original paired E1 Discovery/Skills programme did not reach its human boundary'); +} + +/** Bounded original shuffle control. Byte/UUID entropy is forwarded unchanged. */ +function withShuffle(operation: () => T, source: string[], desired: string[]): T { + assert.deepEqual([...source].sort(), [...desired].sort()); + const working = [...source], rolls: number[] = []; + for (let i = working.length - 1; i > 0; i--) { + const from = working.indexOf(desired[i]); assert.ok(from >= 0 && from <= i); + rolls.push(Math.floor((from + 0.5) * 0x100000000 / (i + 1))); + [working[i], working[from]] = [working[from], working[i]]; + } + const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); + const native = crypto.getRandomValues.bind(crypto); let cursor = 0; + crypto.getRandomValues = (array: V): V => { + if (array instanceof Uint32Array) for (let i = 0; i < array.length; i++) array[i] = 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'); + } +} + +/** Starting hands precede Skills. Supplied hands/offers and actor IDs are + * authoritative. Only an original undealt shuffle is controlled, never redealt. */ +export function initializeDiscoveryPairedNexusE1SkillsSetup( + options: DiscoveryPairedNexusE1SkillsOptions = {}, +): DiscoveryPairedNexusE1SkillsSetup { + const advanced = options.advanced ?? options.initial?.advanced ?? true; + const tech = options.tech ?? (options.initial ? !!options.initial.techTokens : true); + const strongholds = options.strongholds ?? (options.initial ? !!options.initial.strongholdCards : advanced); + let game = options.initial ? structuredClone(options.initial) + : createGame('DISCOVERYPAIREDE1SKILLS', newPlayer('ixians', 'Ixians', 'ixians'), advanced, ['ix']); + if (!options.initial) for (const faction of ['tleilaxu', 'guild', 'fremen'] as const) + joinGame(game, newPlayer(faction, faction, faction)); + assert.deepEqual(game.players.map(p => p.faction).sort(), ['ixians', 'tleilaxu', 'guild', 'fremen'].sort()); + assert.deepEqual(game.expansions, ['ix']); assert.equal(game.advanced, advanced); + assert.ok(!game.homeworlds && !game.ecazTreachery); + assert.ok(game.status === 'lobby' || game.status === 'setup' && game.turn === 1 && game.phase === 0 && + game.leaderSkills?.assignments.length === 0 && game.players.every(p => !p.traitors.length && !p.faceDancers?.length && + p.leaders.every(l => !l.dead && !l.usedAt)), 'Only a genuine original lobby or unassigned native setup may continue'); + const initial = structuredClone(game), actions: DiscoveryPairedNexusE1SkillsStep[] = [], staging: string[] = []; + const seat = (faction: string) => game.players.find(p => p.faction === faction)!.id; + const owner = seat('ixians'), native = seat('tleilaxu'), guild = seat('guild'), fremen = seat('fremen'); + const requested = options.skill ?? 'suk-graduate'; + const passives: LeaderSkillId[] = (['sandmaster', 'smuggler', 'prana-bindu-adept', 'killer-medic'] as LeaderSkillId[]) + .filter(skill => skill !== requested); + const selected: LeaderSkillId[] = game.players.map(p => p.id === owner ? requested : passives.shift()!); + if (game.status === 'lobby') { + assert.ok(!game.leaderSkills); + for (const [type, enabled, present] of [ + ['techTokens', tech, !!game.techTokens], ['strongholdCards', strongholds, !!game.strongholdCards], + ] as const) if (enabled !== present) + game = stepDiscoveryPairedNexusE1Skills(game, { actor: game.host, action: { type, enabled } }, actions); + game.discoveryEnabled = true; game.nexusCards ??= { cards: null, phase: null }; + assert.deepEqual(game.nexusCards, { cards: null, phase: null }); + for (const p of game.players) if (!p.ready) + game = stepDiscoveryPairedNexusE1Skills(game, { actor: p.id, action: { type: 'ready' } }, actions); + const source = LEADER_SKILL_CARDS.map(c => c.id), remainder = source.filter(s => !selected.includes(s)); + const desired = selected.flatMap(s => [s, remainder.shift()!]); + game = withShuffle(() => initializeLeaderSkillsGameForAudit(game), source, [...desired, ...remainder]); + staging.push('Controlled original all14 shuffle before original starting-card selection; native all47 first hands and actual two-card offers are never replaced.'); + } + assert.ok(pairedNexusLeaderSkillsProfile(game)); assert.equal(game.discoveryEnabled, true); + assert.equal(!!game.techTokens, tech); assert.equal(!!game.strongholdCards, strongholds); + assert.ok(game.discoveries && game.nexusCards?.cards && game.leaderSkills); + const setup = structuredClone(game); let offered = structuredClone(game), trainer = ''; + for (let n = 0; game.status === 'setup' && n < 250; n++) { + let step: DiscoveryPairedNexusE1SkillsStep; + if (game.setupStage === 'leaderSkills') { + if (offered.setupStage !== 'leaderSkills') offered = structuredClone(game); + const actor = Object.keys(game.leaderSkills!.offers)[0], offer = game.leaderSkills!.offers[actor]; + const projection = viewGame(game, actor).leaderSkills!; + const legal = offer.cards.filter(c => !projection.unavailableSkills?.[c]); + const desired = selected[game.players.findIndex(p => p.id === actor)]; + const skill = actor === owner && options.skill ? legal.find(c => c === options.skill) + : options.initial?.status === 'setup' ? legal[0] : legal.find(c => c === desired) ?? legal[0]; + const eligible = new Set(projection.eligibleLeaders.map(l => l.id)); + const leader = pairedDiscoveryNexusPlayer(game, actor).leaders.filter(l => eligible.has(l.id)) + .sort((a, b) => b.strength - a.strength)[0]; + assert.ok(skill && leader, 'Explicit training must be available in the original offer on an original eligible living disc'); + if (actor === owner) trainer = leader.id; + step = { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: leader.id } }; + } else { + const next = nextDiscoveryPairedNexusE1SkillsStep(game); assert.ok(next); step = next; + if (game.setupStage === 'traitors' && step.action.type === 'traitor') { + const card = pairedDiscoveryNexusPlayer(game, step.actor).traitorChoices.find(id => id !== trainer); + assert.ok(card); step.action.leader = card; + } + } + if (game.setupStage === 'forces') { + const held = new Set(game.players.flatMap(p => p.traitors)); + const source = traitorDeck(game.players, true).filter(id => !held.has(id)); + assert.ok(source.includes(trainer)); actions.push(structuredClone(step)); + game = withShuffle(() => applyAction(game, step.actor, step.action), source, [trainer, ...source.filter(id => id !== trainer)]); + staging.push('Controlled original undealt Traitor remainder shuffle before actual native three-Face-Dancer draw; no held identity, reveal flag or replacement history is assigned.'); + } else game = stepDiscoveryPairedNexusE1Skills(game, step, actions); + } + assert.equal(game.status, 'playing'); + const assignment = game.leaderSkills!.assignments.find(a => a.owner === owner)!; + return { initial, setup, offered, afterSetup: structuredClone(game), game, owner, native, guild, fremen, + trainer: assignment.leader, skill: assignment.skill, actions, staging }; +} + +/** Printed blow, actual paid typed collection and real inspection/reveal precede + * first END Mentat. The endpoint is free entry BEFORE next Storm, not a fake clock. */ +export function createDiscoveryPairedNexusE1SkillsEntry( + options: DiscoveryPairedNexusE1SkillsOptions = {}, +): DiscoveryPairedNexusE1SkillsEntry { + const s = initializeDiscoveryPairedNexusE1SkillsSetup(options); + const { owner, native, guild, fremen, trainer, skill, actions, staging } = s; + let game = s.game; + const take = (matches: (card: Game['spiceDeck'][number]) => boolean) => { + const at = game.spiceDeck.findIndex(matches); assert.ok(at >= 0); return game.spiceDeck.splice(at, 1)[0]; + }; + const ordered = [take(c => 'territory' in c && c.discovery === 'discovery-hagga-basin')]; + if (game.advanced) ordered.push(take(c => 'territory' in c && !c.discovery && c.territory === 'broken_land')); + ordered.push(take(c => 'worm' in c && !!c.greatMaker)); + ordered.push(take(c => 'territory' in c && c.discovery === 'discovery-sihaya-ridge')); + if (game.advanced) ordered.push(take(c => 'territory' in c && !c.discovery && c.territory === 'rock_outcroppings')); + game.spiceDeck.unshift(...ordered); + staging.push('Conserved original unplayed Spice order: printed Hagga Basin and optional Advanced Broken Land, then Great Maker, Sihaya Ridge and optional Advanced Rock Outcroppings.'); + game = advanceDiscoveryPairedNexusE1Skills(game, g => g.phase === 1 && clean(g), actions); + for (let n = 0; !game.discoveries!.tokens.some(t => t.face === 'shrine' && t.status === 'placed') && n < 100; n++) { + const step = nextDiscoveryPairedNexusE1SkillsStep(game); assert.ok(step); actions.push(structuredClone(step)); + game = withClassicDiscoveryNexusToken(game, 'shrine', () => applyAction(game, step.actor, step.action)); + } + const token = game.discoveries!.tokens.find(t => t.face === 'shrine')!; assert.equal(token.status, 'placed'); + staging.push('Actual original supply-token lottery selects Shrine at the printed first blow; no token face, status or location is assigned.'); + const printed = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'], source = `${printed.territory}:${printed.sector}`; + game = advanceDiscoveryPairedNexusE1Skills(game, g => g.phase === 5 && g.active === owner && clean(g), actions); + game = stepDiscoveryPairedNexusE1Skills(game, { actor: owner, action: { type: 'ship', territory: printed.territory, + sector: printed.sector, amount: 3, elite: 1, allyPayment: 0 } }, actions); + game = advanceDiscoveryPairedNexusE1Skills(game, g => g.phase === 7 && clean(g), actions); + const collection = structuredClone(game); + for (const reveal of [false, true]) game = stepDiscoveryPairedNexusE1Skills(game, + { actor: owner, action: { type: 'discovery', token: token.id, reveal } }, actions); + game = advanceDiscoveryPairedNexusE1Skills(game, g => g.phase === 8 && clean(g), actions); + reservePairedDiscoveryNexusBoard(game, [source, MOBILE_LOCATION]); + placePairedDiscoveryNexus(game, owner, `arrakeen:${territory('arrakeen').sectors[0]}`, 1); + placePairedDiscoveryNexus(game, native, 'hagga_basin:12', 2); + staging.push('Before real END Mentat, conserve unrelated counters into original reserves, retain paid parent/HMS groups, place one Arrakeen claim and two native Maker-source counters. No wallet, earned Card, training or phase is assigned.'); + let firstMentat: PairedDiscoveryNexusTransition | undefined; + while (game.turn === 1) { + const before = structuredClone(game), step = nextDiscoveryPairedNexusE1SkillsStep(game); assert.ok(step); + game = stepDiscoveryPairedNexusE1Skills(game, step, actions); + if (game.turn === 2) firstMentat = { before, step, after: structuredClone(game) }; + } + assert.ok(firstMentat); + game = advanceDiscoveryPairedNexusE1Skills(game, g => g.decision?.kind === 'discoveryEntry', actions); + assert.equal(game.phase, 0); assert.equal(game.decision!.player, owner); + return { initial: s.initial, setup: s.setup, offered: s.offered, afterSetup: s.afterSetup, collection, firstMentat, + game, family: 'ix', owner, native, collector: owner, guild, fremen, token: token.id, source, claim: 'arrakeen', + trainer, skill, actions, staging }; +} + +/** Human typed entry, exact storm-order votes/ride and settled classic alliance + * precede the real end-Spice choice. Both Advanced piles are processed natively. */ +export function closeDiscoveryPairedNexusE1Skills(f: DiscoveryPairedNexusE1SkillsEntry): PairedDiscoveryNexusClosing { + const before = structuredClone(f.game), offer = viewGame(before, f.owner).discoveryEntry!; + const step: DiscoveryPairedNexusE1SkillsStep = { actor: f.owner, action: { type: 'decision', event: offer.event, + accept: true, groups: [{ source: f.source, normal: 1, elite: 1 }] } }; + let game = stepDiscoveryPairedNexusE1Skills(before, step, f.actions); + const entry = { before, step, after: structuredClone(game) }; + game = advanceDiscoveryPairedNexusE1Skills(game, g => g.greatMaker?.stage === 'vote', f.actions); + const vote = structuredClone(game); + while (game.decision?.kind === 'greatMakerVote') game = stepDiscoveryPairedNexusE1Skills(game, + { actor: game.decision.player, action: { type: 'decision', event: game.decision.event, yes: true } }, f.actions); + assert.equal(game.decision?.kind, 'greatMakerRide'); assert.equal(game.decision!.player, f.fremen); + const rideBefore = structuredClone(game); + const action = greatMakerRideAction(viewGame(game, f.fremen), 'polar_sink', 0, 2, game.advanced ? 1 : 0); assert.ok(action); + const rideStep = { actor: f.fremen, action }; game = stepDiscoveryPairedNexusE1Skills(game, rideStep, f.actions); + const ride = { before: rideBefore, step: rideStep, after: structuredClone(game) }; + game = advanceDiscoveryPairedNexusE1Skills(game, g => !!g.nexus && !g.spiceWindow && !g.spiceResolution && clean(g), f.actions); + for (const [actor, target] of [[f.guild, f.fremen], [f.fremen, f.guild]]) + game = stepDiscoveryPairedNexusE1Skills(game, { actor, action: { type: 'alliance', target } }, f.actions); + const alliance = structuredClone(game); + game = advanceDiscoveryPairedNexusE1Skills(game, g => g.nexusCards?.phase?.stage === 'drawing', f.actions); + const cards = game.nexusCards!.cards!; assert.ok(cards.deck.includes('ixians') && cards.deck.includes('tleilaxu')); + cards.deck = ['ixians', 'tleilaxu', ...cards.deck.filter(c => c !== 'ixians' && c !== 'tleilaxu')]; + f.staging.push('Conserved undealt all12 singleton order only; native Cunning hands are earned by actual unallied closing choices after both Spice piles and settled classic alliance.'); + const drawing = structuredClone(game); + for (const actor of [f.owner, f.native]) game = stepDiscoveryPairedNexusE1Skills(game, + { actor, action: { type: 'nexusCardChoice', turn: game.turn, card: cards.hands[actor], choice: 'draw', ownRedraws: 0 } }, f.actions); + game = advanceDiscoveryPairedNexusE1Skills(game, g => g.nexusCards?.phase?.stage !== 'drawing', f.actions); + return { entry, vote, ride, alliance, drawing, afterDraw: structuredClone(game) }; +} + +/** Turn-two physical consumer, retaining original first hands. Eight Ix counters + * (six Suboids/two Cyborgs) make Suk rescue leave a genuine Cyborg Tank loss even + * under full Suboid Cunning. A second conflict keeps cleanup in native Battle. */ +export function prepareDiscoveryPairedNexusE1SkillsBattle(f: DiscoveryPairedNexusE1SkillsEntry, + closing = closeDiscoveryPairedNexusE1Skills(f)): DiscoveryPairedNexusE1SkillsBattle { + let game = advanceDiscoveryPairedNexusE1Skills(closing.afterDraw, g => g.phase === 5 && clean(g), f.actions); + reservePairedDiscoveryNexusBoard(game, [MOBILE_LOCATION]); + const location = 'shrine:0'; + placePairedDiscoveryNexus(game, f.owner, location, 8, 2); + placePairedDiscoveryNexus(game, f.native, location, 8); + placePairedDiscoveryNexus(game, f.native, f.source, 3); + const other = gameTerritories(game).find(t => t.type === 'sand' && t.id !== 'hagga_basin' && t.id !== 'gara_kulon' && + t.sectors.some(s => s >= 9 && s !== game.storm)); assert.ok(other); + const key = `${other.id}:${other.sectors.find(s => s >= 9 && s !== game.storm)}`; + for (const actor of [f.owner, f.native]) placePairedDiscoveryNexus(game, actor, key, 1); + const enemy = pairedDiscoveryNexusPlayer(game, f.native).leaders.filter(l => !l.dead && !isAuditorLeader(l) && + !game.leaderSkills!.assignments.some(a => a.leader === l.id)).sort((a, b) => a.strength - b.strength)[0]; assert.ok(enemy); + const own = pairedDiscoveryNexusPlayer(game, f.owner); + const weapon = own.hand.find(c => c.kind === 'projectile' || c.kind === 'worthless')?.id ?? null; + const defense = own.hand.find(c => c.kind === 'shield')?.id ?? null; + assert.ok(pairedDiscoveryNexusPlayer(game, f.native).faceDancers!.some(c => c.leader === f.trainer && !c.revealed)); + f.staging.push('Before native Movement completion, conserve original counters into revealed Shrine, native parent replacement sources and second conflict. All held Treachery remains actually dealt; no card injection or identity exchange.'); + game = advanceDiscoveryPairedNexusE1Skills(game, g => g.phase === 6 && clean(g), f.actions); + const beforeBattle = structuredClone(game), actor = game.active!; assert.ok(actor === f.owner || actor === f.native); + const battleStep: DiscoveryPairedNexusE1SkillsStep = { actor, + action: { type: 'chooseBattle', territory: 'shrine', target: actor === f.owner ? f.native : f.owner } }; + game = stepDiscoveryPairedNexusE1Skills(game, battleStep, f.actions); + const plans: DiscoveryPairedNexusE1SkillsStep[] = [ + { actor: f.owner, action: { type: 'battlePlan', leader: f.trainer, dial: 8, + support: game.advanced ? 2 : 0, weapon, defense } }, + { actor: f.native, action: { type: 'battlePlan', leader: enemy.id, dial: 0, support: 0, weapon: null, defense: null } }, + ]; + return { entry: f, closing, game, beforeBattle, battleStep, owner: f.owner, opponent: f.native, + trainer: f.trainer, skill: f.skill, enemy: enemy.id, location, replacementSource: f.source, + weapon, defense, plans, actions: f.actions, staging: f.staging }; +} +export function createDiscoveryPairedNexusE1SkillsBattle(options: DiscoveryPairedNexusE1SkillsOptions = {}): DiscoveryPairedNexusE1SkillsBattle { + return prepareDiscoveryPairedNexusE1SkillsBattle(createDiscoveryPairedNexusE1SkillsEntry(options)); +} +export function prepareDiscoveryPairedNexusE1SkillsPlans(f: DiscoveryPairedNexusE1SkillsBattle, state = f.game): Game { + let game = structuredClone(state); + for (let n = 0; n < 160; n++) { + const step = nextDiscoveryPairedNexusE1SkillsStep(game); + if (!step) { assert.ok(game.battle && !game.battle.revealed); return game; } + if (step.action.type === 'leaderSkillVisibility') step.action.hide = step.actor === f.owner; + game = stepDiscoveryPairedNexusE1Skills(game, step, f.actions); + } + throw Error('Original paired E1 skill posture did not reach private plans'); +} +export function beginDiscoveryPairedNexusE1SkillsCunning(f: DiscoveryPairedNexusE1SkillsBattle, + state = prepareDiscoveryPairedNexusE1SkillsPlans(f)): Game { + const offer = viewGame(state, f.owner).nexusSuboids?.offer; assert.ok(offer); assert.equal(offer.blocked, null); + return stepDiscoveryPairedNexusE1Skills(state, { actor: f.owner, action: { type: 'nexusSuboids', event: offer.event } }, f.actions); +} +/** Suggested high-dial plans consume actual Cunning first. Humans may replace + * either suggested plan and explicitly opt out with their own legal dial. */ +export function revealDiscoveryPairedNexusE1SkillsBattle(f: DiscoveryPairedNexusE1SkillsBattle, + options: { state?: Game; cunning?: boolean; plans?: DiscoveryPairedNexusE1SkillsStep[] } = {}): Game { + let game = prepareDiscoveryPairedNexusE1SkillsPlans(f, options.state ?? f.game); + if (options.cunning !== false) game = advanceDiscoveryPairedNexusE1Skills(beginDiscoveryPairedNexusE1SkillsCunning(f, game), clean, f.actions); + for (const step of options.plans ?? f.plans) { + game = stepDiscoveryPairedNexusE1Skills(game, step, f.actions); + while (game.response || game.phaseOpening || game.decision?.kind === 'fullPlanOffer') { + const next = nextDiscoveryPairedNexusE1SkillsStep(game); assert.ok(next); + game = stepDiscoveryPairedNexusE1Skills(game, next, f.actions); + } + } + assert.ok(game.battle?.revealed); return game; +} +export function finishDiscoveryPairedNexusE1SkillsBattle(state: Game, + stop: DiscoveryPairedNexusE1SkillsBoundary = 'complete', actions?: DiscoveryPairedNexusE1SkillsStep[]): Game { + const kinds: Record = { losses: 'battleLosses', rescue: 'sukRescue', + substitution: 'ixSubstitution', cards: 'battleCards', tech: 'techToken', faceDance: 'faceDance', complete: '' }; + return advanceDiscoveryPairedNexusE1Skills(state, game => game.decision?.kind === kinds[stop] || + stop === 'losses' && game.decision?.kind === 'sukRescue' || + stop === 'tech' && game.decision?.kind === 'faceDance' && !game.pendingTech || + !game.battle && clean(game) && !game.pendingTreacheryDiscard, actions); +} +/** Printed graph search only; the actual movement action validates its range. */ +export function discoveryPairedNexusE1SkillsRangeSource(game: Game): string { + const source = gameTerritories(game).flatMap(t => t.type === 'sand' ? t.sectors.map(s => `${t.id}:${s}`) : []) + .find(key => gameDistance(game, key, 'shrine:0', candidate => splitLocation(candidate).sector === game.storm) === 3); + assert.ok(source, 'Revealed original nested graph must supply a three-territory Planetologist consumer'); + return source; +} diff --git a/tests/fixture-discovery-paired-nexus-e2-skills.ts b/tests/fixture-discovery-paired-nexus-e2-skills.ts new file mode 100644 index 00000000..70e58a78 --- /dev/null +++ b/tests/fixture-discovery-paired-nexus-e2-skills.ts @@ -0,0 +1,375 @@ +import assert from 'node:assert/strict'; +import { + applyAction, createGame, joinGame, newPlayer, viewGame, + type Action, type Game, type Player, +} from '../game/engine'; +import { TERRITORIES, territory } from '../game/board'; +import type { Card } from '../game/cards'; +import { DISCOVERY_CARD_PLACEMENTS, DISCOVERY_TOKEN_BY_ID, type DiscoveryTokenFace } from '../game/discoveries'; +import { discoveryEntryMoveAction } from '../game/discovery-entry-options'; +import { greatMakerRideAction } from '../game/great-maker-options'; +import { + completePairedChoamSkillsSetup, initializePairedChoamNexusSkills, nextPairedChoamSkillsStep, + openPairedSkillsBattle, pairedSkillsBattlePlans, pairedSkillsClean, + pairedSkillsPlayer, placePairedSkillsForce, revealPairedSkillsRichese, + type PairedChoamNexusSkillsOptions, +} from './fixture-paired-choam-nexus-skills'; +import { nextDiscoveryNativeE2SkillsStep } from './fixture-discovery-native-e2-skills'; +import { applyPairedDiscoveryNexusStep, reservePairedDiscoveryNexusBoard } from './fixture-discovery-paired-nexus'; + +export type DiscoveryPairedNexusE2SkillsStep = { actor: string; action: Action }; +export type DiscoveryPairedNexusE2SkillsOptions = PairedChoamNexusSkillsOptions & { + tech?: boolean; + strongholds?: boolean; + kind?: 'shrine' | 'stash'; + arrakeenOwner?: 'richese' | 'choam'; +}; +export type DiscoveryPairedNexusE2SkillsTransition = { + before: Game; step: DiscoveryPairedNexusE2SkillsStep; after: Game; +}; +export type DiscoveryPairedNexusE2SkillsAcquisition = DiscoveryPairedNexusE2SkillsTransition & { + buyer: string; card: Card; +}; +export type DiscoveryPairedNexusE2SkillsFixture = { + initial: Game; afterSetup: Game; afterFirstStorm: Game; collection: Game; + previousMarker: DiscoveryPairedNexusE2SkillsTransition; + firstMentat: DiscoveryPairedNexusE2SkillsTransition | null; + game: Game; richese: string; choam: string; guild: string; fremen: string; + collector: string; token: string; face: DiscoveryTokenFace; source: string; + trainers: { richese: string; choam: string }; + actions: DiscoveryPairedNexusE2SkillsStep[]; staging: string[]; + acquisitions: DiscoveryPairedNexusE2SkillsAcquisition[]; +}; +export type DiscoveryPairedNexusE2SkillsClosing = { + entry: DiscoveryPairedNexusE2SkillsTransition; + vote: Game; ride: DiscoveryPairedNexusE2SkillsTransition; + alliance: Game; drawing: Game; draws: DiscoveryPairedNexusE2SkillsTransition[]; movementStart: Game; game: Game; +}; +export type DiscoveryPairedNexusE2SkillsBattle = { + game: Game; plans: DiscoveryPairedNexusE2SkillsStep[]; key: string; +}; +export const discoveryPairedNexusE2SkillsClean = pairedSkillsClean; +export const discoveryPairedNexusE2SkillsPlayer = pairedSkillsPlayer; + +/** Original selections only. This entry point also serves human two-to-six-seat + * starts; the longer paired-draw programme needs Guild and Fremen seats. + * Existing hands, IDs and all14 offers are never replaced or redealt. */ +export function initializeDiscoveryPairedNexusE2Skills(options: DiscoveryPairedNexusE2SkillsOptions = {}): Game { + const advanced = options.advanced ?? options.initial?.advanced ?? true; + const factions = options.factions ?? ['richese', 'choam', 'guild', 'fremen'] as const; + let game = options.initial ? structuredClone(options.initial) + : createGame('DISCOVERYPAIREDNEXUSE2SKILLS', newPlayer(options.seatIds?.[0] ?? factions[0], factions[0], factions[0]), advanced, ['choam']); + if (!options.initial) for (const faction of factions.slice(1)) + joinGame(game, newPlayer(options.seatIds?.[game.players.length] ?? faction, faction, faction)); + const tech = options.tech ?? !!game.techTokens; + const strongholds = options.strongholds ?? !!game.strongholdCards; + assert.equal(game.advanced, advanced); + assert.ok(game.players.length >= (tech ? 3 : 2) && game.players.length <= 6); + assert.ok(!strongholds || advanced, 'Original Stronghold Cards require Advanced.'); + if (game.status === 'lobby') { + if (!!game.techTokens !== tech) game = applyAction(game, game.host, { type: 'techTokens', enabled: tech }); + if (!!game.strongholdCards !== strongholds) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: strongholds }); + game.discoveryEnabled = true; + } else { + assert.equal(game.discoveryEnabled, true); + assert.equal(!!game.techTokens, tech, 'Saved native module selection is authoritative.'); + assert.equal(!!game.strongholdCards, strongholds); + } + // The existing initializer controls only fresh original offer entropy. + return initializePairedChoamNexusSkills({ ...options, initial: game, advanced, + richeseSkill: options.initial ? options.richeseSkill : options.richeseSkill ?? 'suk-graduate', + choamSkill: options.initial ? options.choamSkill : options.choamSkill ?? 'smuggler' }); +} + +/** Reuse native response, auction, Guild, skill and original Discovery producers. + * Owned entry, Maker and battle-plan endpoints remain explicit human actions. */ +export function nextDiscoveryPairedNexusE2SkillsStep(game: Game): DiscoveryPairedNexusE2SkillsStep | null { + if (game.status === 'setup' && game.setupStage === 'leaderSkills') return nextPairedChoamSkillsStep(game); + if (pairedSkillsClean(game) && game.nexusCards?.phase?.stage === 'drawing') return nextPairedChoamSkillsStep(game); + if (!game.phaseOpening && !game.response && game.decision) { + const d = game.decision; + 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: false } }; + if (d.kind === 'discoveryDiscard') { + const card = pairedSkillsPlayer(game, d.player).hand[0]; assert.ok(card); + return { actor: d.player, action: { type: 'decision', event: d.event, card: card.id } }; + } + if (d.kind === 'faceDance') return null; + } + return nextDiscoveryNativeE2SkillsStep(game, true); +} +export function stepDiscoveryPairedNexusE2Skills(game: Game, step: DiscoveryPairedNexusE2SkillsStep, + actions?: DiscoveryPairedNexusE2SkillsStep[]): Game { + // Existing original scalar entropy; not a fake clock or a fabricated receipt. + return applyPairedDiscoveryNexusStep(game, step, actions); +} +export function advanceDiscoveryPairedNexusE2Skills(game: Game, until: (g: Game) => boolean, + actions?: DiscoveryPairedNexusE2SkillsStep[]): Game { + for (let n = 0; n < 2400; n++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished'); + const step = nextDiscoveryPairedNexusE2SkillsStep(game); + assert.ok(step, 'Continue the original human plan or Face Dance explicitly.'); + game = stepDiscoveryPairedNexusE2Skills(game, step, actions); + } + throw Error('Original paired E2 Discovery/Skills programme did not reach its boundary.'); +} +export function settleDiscoveryPairedNexusE2Skills(game: Game, actions?: DiscoveryPairedNexusE2SkillsStep[]): Game { + return advanceDiscoveryPairedNexusE2Skills(game, g => pairedSkillsClean(g) && + !g.pendingShipment && !g.pendingTreacheryDiscard && !g.pendingChoamWorthless, actions); +} +export function completeDiscoveryPairedNexusE2SkillsSetup(game: Game, + options: DiscoveryPairedNexusE2SkillsOptions = {}, actions?: DiscoveryPairedNexusE2SkillsStep[]): Game { + return completePairedChoamSkillsSetup(game, options, actions); +} +function withToken(game: Game, face: DiscoveryTokenFace, run: () => T): T { + const supply = game.discoveries!.tokens.filter(t => t.status === 'supply' && t.type === DISCOVERY_TOKEN_BY_ID[face].type); + const index = supply.findIndex(t => t.face === face); assert.ok(index >= 0); + const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); + const native = crypto.getRandomValues.bind(crypto); + crypto.getRandomValues = (array: V): V => { + if (array instanceof Uint32Array && array.length === 1) + array[0] = Math.floor((index + 0.5) * 0x100000000 / supply.length); + else Reflect.apply(native, crypto, [array]); + return array; + }; + try { return run(); } + finally { + if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); + else Reflect.deleteProperty(crypto, 'getRandomValues'); + } +} +function orderSpice(game: Game, printed: string, staging: string[]): void { + const take = (matches: (c: Game['spiceDeck'][number]) => boolean) => { + const index = game.spiceDeck.findIndex(matches); assert.ok(index >= 0); + return game.spiceDeck.splice(index, 1)[0]; + }; + const cards = [take(c => 'territory' in c && c.discovery === printed)]; + if (game.advanced) cards.push(take(c => 'territory' in c && !c.discovery && c.territory === 'broken_land')); + cards.push(take(c => 'worm' in c && !!c.greatMaker)); + cards.push(take(c => 'territory' in c && c.discovery === 'discovery-sihaya-ridge')); + if (game.advanced) cards.push(take(c => 'territory' in c && !c.discovery && c.territory === 'rock_outcroppings')); + game.spiceDeck.unshift(...cards); + staging.push('Conserved original unplayed printed Discovery, optional Advanced Broken Land, Great Maker, Sihaya Ridge and optional Advanced Rock Outcroppings order.'); +} +/** Only order an undealt physical card. Actual auction bids acquire it; never + * move cards from other hands/discards or manufacture a held support card. */ +export function orderDiscoveryPairedNexusE2SkillsCard(game: Game, matches: (c: Card) => boolean, staging: string[]): Card { + const index = game.deck.findIndex(matches); assert.ok(index >= 0, 'The requested original card must still be undealt.'); + const card = game.deck.splice(index, 1)[0]; game.deck.unshift(card); + staging.push(`Conserved original undealt ${card.id} at the next ordinary auction front; no held-card injection.`); + return card; +} +/** Preserve first hands. Buy only still-undealt requested cards through native + * Bidding; a genuine existing Karama holder remains the eligible responder. */ +function prepareOrdinaryAcquisitions(game: Game, choam: string, fremen: string, staging: string[]): Map { + const buys = new Map(); + if (!pairedSkillsPlayer(game, choam).hand.some(c => c.name === 'Kulon' && c.kind === 'worthless')) { + const card = game.deck.find(c => c.name === 'Kulon' && c.kind === 'worthless'); + if (card) buys.set(orderDiscoveryPairedNexusE2SkillsCard(game, c => c.id === card.id, staging).id, choam); + } + if (!pairedSkillsPlayer(game, choam).hand.some(c => c.kind === 'special' && c.effect !== 'karama')) { + const card = game.deck.find(c => c.kind === 'special' && c.effect !== 'karama'); + assert.ok(card); + buys.set(orderDiscoveryPairedNexusE2SkillsCard(game, c => c.id === card.id, staging).id, choam); + } + if (!game.players.some(p => p.faction !== 'richese' && p.hand.some(c => c.effect === 'karama'))) { + const card = orderDiscoveryPairedNexusE2SkillsCard(game, c => c.effect === 'karama', staging); + buys.set(card.id, fremen); + } + return buys; +} +function advanceWithAcquisitions(game: Game, until: (g: Game) => boolean, buys: Map, + actions: DiscoveryPairedNexusE2SkillsStep[], acquisitions: DiscoveryPairedNexusE2SkillsAcquisition[]): Game { + for (let n = 0; n < 2400; n++) { + if (until(game)) { + assert.equal(buys.size, 0, 'Every ordered original lot must have been bought by its intended eligible actor.'); + return game; + } + let step = nextDiscoveryPairedNexusE2SkillsStep(game); assert.ok(step); + const auction = game.auction, card = auction?.cards[auction.index]; + const buyer = card && !game.currentAuctionSale ? buys.get(card.id) : undefined; + // The original no-spend producer emits passBid, not bid. Only the intended + // buyer raises; all other eligible actors use the native passBid action. + if (buyer && auction && (step.action.type === 'bid' || step.action.type === 'passBid')) + step = { actor: auction.active, action: auction.active === buyer && auction.bidder !== buyer + ? { type: 'bid', amount: auction.bid + 1 } : { type: 'passBid' } }; + const before = game; + game = stepDiscoveryPairedNexusE2Skills(game, step, actions); + if (buyer && card && pairedSkillsPlayer(game, buyer).hand.some(c => c.id === card.id)) { + assert.equal(auction!.bidder, buyer); + assert.equal(auction!.bid, 1); + assert.equal(pairedSkillsPlayer(game, buyer).spice, pairedSkillsPlayer(before, buyer).spice - 1); + acquisitions.push({ before: structuredClone(before), step: structuredClone(step), + after: structuredClone(game), buyer, card: structuredClone(card) }); + buys.delete(card.id); + } + } + throw Error('Original card acquisition did not reach its boundary.'); +} + +/** Skill-first original start → zero-marker invoice/reveal → real paid typed + * collection → inspect/reveal. Stops before Stash disposal or next-turn entry. + * Board controls are labeled conserved positions, never claimed played history. */ +export function createDiscoveryPairedNexusE2SkillsFixture(options: DiscoveryPairedNexusE2SkillsOptions = {}): DiscoveryPairedNexusE2SkillsFixture { + let game = initializeDiscoveryPairedNexusE2Skills(options); + const initial = structuredClone(game), actions: DiscoveryPairedNexusE2SkillsStep[] = [], staging: string[] = [ + 'Fresh offers use the existing original all14 shuffle control; saved offers and first hands are authoritative. Later original scalar lotteries use the existing paired Discovery scheduler.', + ]; + const acquisitions: DiscoveryPairedNexusE2SkillsAcquisition[] = []; + game = completeDiscoveryPairedNexusE2SkillsSetup(game, options, actions); + const afterSetup = structuredClone(game); + const seat = (faction: string) => { const p = game.players.find(p => p.faction === faction); assert.ok(p); return p.id; }; + const richese = seat('richese'), choam = seat('choam'), guild = seat('guild'), fremen = seat('fremen'); + const face: DiscoveryTokenFace = options.kind === 'stash' ? 'treachery-card-stash' : 'shrine'; + const printed = face === 'shrine' ? 'discovery-hagga-basin' : 'discovery-wind-pass-north'; + const placement = DISCOVERY_CARD_PLACEMENTS[printed], source = `${placement.territory}:${placement.sector}`; + const collector = face === 'shrine' ? guild : choam; + const buys = prepareOrdinaryAcquisitions(game, choam, fremen, staging); + orderSpice(game, printed, staging); + game = advanceDiscoveryPairedNexusE2Skills(game, g => g.phase === 1 && pairedSkillsClean(g), actions); + const afterFirstStorm = structuredClone(game); + for (let n = 0; !game.discoveries!.tokens.some(t => t.face === face && t.status === 'placed') && n < 100; n++) { + const step = nextDiscoveryPairedNexusE2SkillsStep(game); assert.ok(step); + actions.push(structuredClone(step)); + // Bypass the generic scalar wrapper for this exact native placement lottery. + game = withToken(game, face, () => applyAction(game, step.actor, step.action)); + } + assert.ok(game.discoveries!.tokens.some(t => t.face === face && t.status === 'placed')); + game = advanceWithAcquisitions(game, g => g.phase === 5 && pairedSkillsClean(g), buys, actions, acquisitions); + let previousMarker: DiscoveryPairedNexusE2SkillsTransition | undefined; + let collectedShipment = false; + for (let n = 0; (!previousMarker || !collectedShipment) && n < 200; n++) { + assert.equal(game.turn, 1, 'Do not chase an already-passed original shipment seat into another turn.'); + assert.equal(game.phase, 5); + if (pairedSkillsClean(game) && game.active === richese && !previousMarker) { + const before = structuredClone(game), p = pairedSkillsPlayer(game, richese); + const step: DiscoveryPairedNexusE2SkillsStep = { actor: richese, action: { type: 'ship', + territory: 'habbanya_ridge_sietch', sector: territory('habbanya_ridge_sietch').sectors[0], + noField: p.noField!.tokens.find(t => t.value === 0)!.id, event: p.noFieldEvent, allyPayment: 0 } }; + game = settleDiscoveryPairedNexusE2Skills(stepDiscoveryPairedNexusE2Skills(game, step, actions), actions); + previousMarker = { before, step, after: structuredClone(game) }; + game = revealPairedSkillsRichese(game, richese, actions); + } else if (pairedSkillsClean(game) && game.active === collector && !collectedShipment) { + game = stepDiscoveryPairedNexusE2Skills(game, { actor: collector, action: { type: 'ship', + territory: placement.territory, sector: placement.sector, amount: 3, elite: 0, allyPayment: 0 } }, actions); + game = settleDiscoveryPairedNexusE2Skills(game, actions); + collectedShipment = true; + } else { + const step = nextDiscoveryPairedNexusE2SkillsStep(game); assert.ok(step); + game = stepDiscoveryPairedNexusE2Skills(game, step, actions); + } + } + assert.ok(previousMarker && collectedShipment); + game = advanceDiscoveryPairedNexusE2Skills(game, g => g.phase === 7 && pairedSkillsClean(g), actions); + const collection = structuredClone(game), token = game.discoveries!.tokens.find(t => t.face === face)!; + assert.ok(viewGame(game, collector).discoveries!.canInspect.includes(token.id)); + game = stepDiscoveryPairedNexusE2Skills(game, { actor: collector, action: { type: 'discovery', token: token.id, reveal: false } }, actions); + game = stepDiscoveryPairedNexusE2Skills(game, { actor: collector, action: { type: 'discovery', token: token.id, reveal: true } }, actions); + const trainers = { richese: game.leaderSkills!.assignments.find(a => a.owner === richese)!.leader, + choam: game.leaderSkills!.assignments.find(a => a.owner === choam)!.leader }; + // The Stash branch returns the actual disposal endpoint without auto-discard. + if (face === 'treachery-card-stash') return { initial, afterSetup, afterFirstStorm, collection, previousMarker, + firstMentat: null, game, richese, choam, guild, fremen, collector, token: token.id, face, source, trainers, actions, staging, acquisitions }; + game = advanceDiscoveryPairedNexusE2Skills(game, g => g.phase === 8 && pairedSkillsClean(g), actions); + reservePairedDiscoveryNexusBoard(game, [source]); + placePairedSkillsForce(game, choam, 'hagga_basin:12', 2, staging); + placePairedSkillsForce(game, options.arrakeenOwner === 'richese' ? richese : choam, `arrakeen:${territory('arrakeen').sectors[0]}`, 1, staging); + staging.push('Before actual END Mentat conserve unrelated original board groups to their native reserves; retain the paid parent group and stage CHOAM worm-source/Arrakeen counters. No custody, training or earned invoice is assigned.'); + let firstMentat: DiscoveryPairedNexusE2SkillsTransition | undefined; + while (game.turn === 1) { + const mentatBefore = structuredClone(game), mentatStep = nextDiscoveryPairedNexusE2SkillsStep(game); assert.ok(mentatStep); + game = stepDiscoveryPairedNexusE2Skills(game, mentatStep, actions); + if (game.turn === 2) firstMentat = { before: mentatBefore, step: mentatStep, after: structuredClone(game) }; + } + assert.ok(firstMentat); + game = advanceDiscoveryPairedNexusE2Skills(game, g => g.decision?.kind === 'discoveryEntry', actions); + assert.equal(game.phase, 0); assert.equal(game.decision!.player, collector); + return { initial, afterSetup, afterFirstStorm, collection, previousMarker, firstMentat, game, + richese, choam, guild, fremen, collector, token: token.id, face, source, trainers, actions, staging, acquisitions }; +} + +/** Complete actual end-Mentat free entry, Maker loss/vote/Fremen typed reserve + * ride, both Advanced Spice piles, reciprocal alliance and closing all12 draws. */ +export function closeDiscoveryPairedNexusE2Skills(f: DiscoveryPairedNexusE2SkillsFixture): DiscoveryPairedNexusE2SkillsClosing { + assert.equal(f.face, 'shrine'); + const before = structuredClone(f.game), offer = viewGame(before, f.collector).discoveryEntry!; + const action = discoveryEntryMoveAction(viewGame(before, f.collector), offer.sources); assert.ok(action); + const step = { actor: f.collector, action }; + let game = stepDiscoveryPairedNexusE2Skills(before, step, f.actions); + const entry = { before, step, after: structuredClone(game) }; + game = advanceDiscoveryPairedNexusE2Skills(game, g => g.greatMaker?.stage === 'vote', f.actions); + const vote = structuredClone(game); + while (game.decision?.kind === 'greatMakerVote') game = stepDiscoveryPairedNexusE2Skills(game, + { actor: game.decision.player, action: { type: 'decision', event: game.decision.event, yes: true } }, f.actions); + assert.equal(game.decision?.kind, 'greatMakerRide'); assert.equal(game.decision!.player, f.fremen); + const rideBefore = structuredClone(game), rideAction = greatMakerRideAction(viewGame(game, f.fremen), 'polar_sink', 0, 2, game.advanced ? 1 : 0); + assert.ok(rideAction); + const rideStep = { actor: f.fremen, action: rideAction }; + game = stepDiscoveryPairedNexusE2Skills(game, rideStep, f.actions); + const ride = { before: rideBefore, step: rideStep, after: structuredClone(game) }; + game = advanceDiscoveryPairedNexusE2Skills(game, g => !!g.nexus && !g.spiceWindow && !g.spiceResolution && pairedSkillsClean(g), f.actions); + game = stepDiscoveryPairedNexusE2Skills(game, { actor: f.guild, action: { type: 'alliance', target: f.fremen } }, f.actions); + game = stepDiscoveryPairedNexusE2Skills(game, { actor: f.fremen, action: { type: 'alliance', target: f.guild } }, f.actions); + const alliance = structuredClone(game); + game = advanceDiscoveryPairedNexusE2Skills(game, g => g.nexusCards?.phase?.stage === 'drawing' && pairedSkillsClean(g), f.actions); + const cards = game.nexusCards!.cards!; + for (const face of ['richese', 'choam'] as const) assert.ok(cards.deck.includes(face)); + cards.deck = ['richese', 'choam', ...cards.deck.filter(c => c !== 'richese' && c !== 'choam')]; + f.staging.push('Conserved original unused all12 Richese/CHOAM order before actual qualified end-Spice draws; not a held-card grant.'); + const drawing = structuredClone(game), draws: DiscoveryPairedNexusE2SkillsTransition[] = []; + for (const actor of [f.richese, f.choam]) { + const drawBefore = structuredClone(game), drawStep: DiscoveryPairedNexusE2SkillsStep = { actor, + action: { type: 'nexusCardChoice', turn: game.turn, card: null, choice: 'draw', ownRedraws: 0 } }; + game = stepDiscoveryPairedNexusE2Skills(game, drawStep, f.actions); + draws.push({ before: drawBefore, step: drawStep, after: structuredClone(game) }); + } + game = advanceDiscoveryPairedNexusE2Skills(game, g => g.phase === 5 && pairedSkillsClean(g), f.actions); + const choam = pairedSkillsPlayer(game, f.choam), claim = `arrakeen:${territory('arrakeen').sectors[0]}`; + if (choam.forces[claim]) { + choam.reserves += choam.forces[claim]; delete choam.forces[claim]; + f.staging.push('Conserve the original CHOAM Arrakeen claim counter to native reserves before Movement; its actually held Stronghold Card persists until real END Mentat. This does not claim movement history or add Ornithopter/Kulon mixed entitlement.'); + } + const movementStart = structuredClone(game); + game = advanceDiscoveryPairedNexusE2Skills(game, g => g.phase === 5 && g.active === f.richese && pairedSkillsClean(g), f.actions); + return { entry, vote, ride, alliance, drawing, draws, movementStart, game }; +} +export function discoveryPairedNexusE2SkillsCunningAction(game: Game, richese: string, destination = 'shrine'): Action { + const p = pairedSkillsPlayer(game, richese), offer = viewGame(game, richese).nexusRicheseCunning; + assert.ok(offer && !offer.blocked); + return { type: 'ship', territory: destination, sector: destination === 'shrine' ? 0 : territory(destination).sectors[0], + 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 }; +} +/** Keep face-up pair counters separate from the marker until actual voluntary + * reveal; mixed marker/ordinary battle dial remains a source guard. */ +export function prepareDiscoveryPairedNexusE2SkillsBattle(f: DiscoveryPairedNexusE2SkillsFixture, shipped: Game, + trained = true): DiscoveryPairedNexusE2SkillsBattle { + const deployed = pairedSkillsPlayer(shipped, f.richese).noField!.deployed; assert.ok(deployed); + const destination = deployed.location.territory, key = `${destination}:${deployed.location.sector}`; + let game = revealPairedSkillsRichese(shipped, f.richese, f.actions); + assert.equal(pairedSkillsPlayer(game, f.richese).noField!.deployed, null); + if (destination !== 'shrine') placePairedSkillsForce(game, f.guild, key, 3, f.staging); + // The actual Guild free-entry group supplies opposing nested counters. + game = openPairedSkillsBattle(game, f.richese, f.guild, { territory: destination, band: trained ? 'skilled' : 'normal', actions: f.actions }); + return { game, key, plans: pairedSkillsBattlePlans(game, f.richese, f.guild, 2, trained) }; +} +export function finishDiscoveryPairedNexusE2SkillsBattle(game: Game, actions?: DiscoveryPairedNexusE2SkillsStep[]): Game { + return advanceDiscoveryPairedNexusE2Skills(game, g => !g.battle && pairedSkillsClean(g) && !g.pendingTreacheryDiscard, actions); +} +export function discoveryPairedNexusE2SkillsInvoiceAction(game: Game): { action: Action; key: string } { + const target = TERRITORIES.find(t => t.type === 'stronghold' && !t.sectors.includes(game.storm) && + game.players.every(p => Object.entries(p.forces).every(([key, n]) => !n || !key.startsWith(`${t.id}:`)))); + assert.ok(target); + return { action: { type: 'ship', territory: target.id, sector: target.sectors[0], amount: 6, elite: 0, allyPayment: 0, smuggler: true }, + key: `${target.id}:${target.sectors[0]}` }; +} +/** Actual holder, not a generic first actor. The receipt caller can stop on its + * signed native response and let each minimal policy pass/cancel a real card. */ +export function discoveryPairedNexusE2SkillsKaramaHolder(game: Game, exclude?: string): Player { + const holder = game.players.find(p => p.id !== exclude && p.hand.some(c => c.effect === 'karama')); + assert.ok(holder, 'Acquire an actual original Karama through setup or Bidding.'); + return holder; +} diff --git a/tests/fixture-discovery-standalone-nexus-e3-skills.ts b/tests/fixture-discovery-standalone-nexus-e3-skills.ts new file mode 100644 index 00000000..0fb8cad9 --- /dev/null +++ b/tests/fixture-discovery-standalone-nexus-e3-skills.ts @@ -0,0 +1,492 @@ +import assert from 'node:assert/strict'; +import { + applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, viewGame, + type Action, type Game, type Player, +} from '../game/engine'; +import { territory } from '../game/board'; +import { treacheryDeck } from '../game/cards'; +import type { FactionId } from '../game/catalog'; +import { DISCOVERY_CARD_PLACEMENTS, validateDiscoveryState } from '../game/discoveries'; +import { discoveryEntryMoveAction } from '../game/discovery-entry-options'; +import { greatMakerRideAction } from '../game/great-maker-options'; +import { LEADER_SKILL_CARDS, 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 type { StrongholdId } from '../game/stronghold-cards'; +import { + nextEcazNexusSkillsModulesStep, ecazNexusSkillsModulesCunningRequest, +} from './fixture-ecaz-nexus-skills-modules'; +import { + nextMoritaniNexusSkillsModulesStep, moritaniNexusSkillsModulesPolicy, + moritaniNexusSkillsModulesRequest, +} from './fixture-moritani-nexus-skills-modules'; +import { revealDiscoveryNativeE3SkillsPlans } from './fixture-discovery-native-e3-skills'; +import { orderDiscoveryClassicNexusSkillsSpice } from './fixture-discovery-classic-nexus-skills'; +import { + nextClassicDiscoveryNexusStep, withClassicDiscoveryNexusLottery, withClassicDiscoveryNexusToken, +} from './fixture-discovery-classic-nexus'; + +export type DiscoveryStandaloneNexusE3SkillsNative = 'ecaz' | 'moritani'; +export type DiscoveryStandaloneNexusE3SkillsStep = { actor: string; action: Action }; +export type DiscoveryStandaloneNexusE3SkillsTransition = { + before: Game; step: DiscoveryStandaloneNexusE3SkillsStep; after: Game; +}; +export type DiscoveryStandaloneNexusE3SkillsOptions = { + /** A fresh lobby or its ORIGINAL unassigned setup. Never a played-save conversion. */ + initial?: Game; + native?: DiscoveryStandaloneNexusE3SkillsNative; + rules?: 'basic' | 'advanced'; + factions?: readonly FactionId[]; + seatIds?: readonly string[]; + tech?: boolean; + strongholds?: boolean; + /** Explicit requests must be genuinely offered; absent requests accept the human lottery. */ + skill?: LeaderSkillId; + leader?: string; +}; +export type DiscoveryStandaloneNexusE3SkillsFixture = { + initial: Game; setup: Game; trained: Game; afterFirstStorm: Game; collection: Game; + firstMentat: DiscoveryStandaloneNexusE3SkillsTransition; + game: Game; native: DiscoveryStandaloneNexusE3SkillsNative; + owner: string; opponent: string; fremen: string | null; + leader: string; skill: LeaderSkillId; target: string | null; + token: string; source: string; wormSource: string; claim: StrongholdId; + actions: DiscoveryStandaloneNexusE3SkillsStep[]; staging: string[]; +}; +export type DiscoveryStandaloneNexusE3SkillsClosing = { + entered: Game; vote: Game; ride: DiscoveryStandaloneNexusE3SkillsTransition | null; + alliance: Game; draw: DiscoveryStandaloneNexusE3SkillsTransition; game: Game; +}; +export type DiscoveryStandaloneNexusE3SkillsBattle = { + beforeBattle: Game; leadChoice: DiscoveryStandaloneNexusE3SkillsTransition | null; + game: Game; plans: DiscoveryStandaloneNexusE3SkillsStep[]; + actor: string; opponent: string; leader: string; enemyLeader: string; + location: string; card: string; enemyCard?: string; target: string | null; +}; +export type DiscoveryStandaloneNexusE3SkillsBoundary = { + game: Game; kind: 'losses' | 'rescue' | 'assassination' | 'cards' | 'tech' | 'settled'; +}; +export const discoveryStandaloneNexusE3SkillsPolicy = moritaniNexusSkillsModulesPolicy; +export const discoveryStandaloneNexusE3SkillsClean = (game: Game): boolean => + !game.response && !game.phaseOpening && !game.decision && !game.pendingTreacheryDiscard && !game.truthtrance; +export function discoveryStandaloneNexusE3SkillsPlayer(game: Game, actor: string): Player { + const player = game.players.find(p => p.id === actor); assert.ok(player); return player; +} +const copy = (game: Game): Game => structuredClone(game); + +/** Scalar entropy only for ONE physical shuffle; preceding scalar draws, UUIDs and later production stay native. */ +function shuffled(source: readonly string[], desired: readonly string[], operation: () => T, precedingDraws = 0): T { + assert.deepEqual([...source].sort(), [...desired].sort()); + const working = [...source], rolls: number[] = []; + for (let index = working.length - 1; index > 0; index--) { + const swap = working.indexOf(desired[index]); assert.ok(swap >= 0 && swap <= index); + rolls.push(Math.floor((swap + 0.5) * 0x100000000 / (index + 1))); + [working[index], working[swap]] = [working[swap], working[index]]; + } + const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); + const original = crypto.getRandomValues.bind(crypto); let cursor = 0; + crypto.getRandomValues = (array: V): V => { + if (array instanceof Uint32Array && array.length === 1 && precedingDraws > 0) { + precedingDraws--; Reflect.apply(original, crypto, [array]); + } else if (array instanceof Uint32Array && array.length === 1 && cursor < rolls.length) array[0] = rolls[cursor++]; + else Reflect.apply(original, crypto, [array]); + return array; + }; + try { return operation(); } + finally { + if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); + else Reflect.deleteProperty(crypto, 'getRandomValues'); + } +} +export function initializeDiscoveryStandaloneNexusE3Skills(options: DiscoveryStandaloneNexusE3SkillsOptions = {}): Game { + const native = options.native ?? options.initial?.players.find(p => p.faction === 'ecaz' || p.faction === 'moritani')?.faction ?? 'moritani'; + const advanced = options.rules ? options.rules === 'advanced' : options.initial?.advanced ?? true; + const factions = options.factions ?? [native, 'guild', 'fremen', ...(!advanced && native === 'ecaz' ? ['emperor'] as const : [])]; + let game = options.initial ? copy(options.initial) : createGame('DISCOVERYSTANDALONEE3SKILLS', + newPlayer(options.seatIds?.[0] ?? factions[0], factions[0], factions[0]), advanced, ['ecaz']); + if (!options.initial) for (const faction of factions.slice(1)) + joinGame(game, newPlayer(options.seatIds?.[game.players.length] ?? faction, faction, faction)); + const tech = options.tech ?? !!game.techTokens, strongholds = options.strongholds ?? !!game.strongholdCards; + assert.equal(game.advanced, advanced); assert.deepEqual(game.expansions, ['ecaz']); + assert.ok(game.players.length >= (tech ? 3 : 2) && game.players.length <= 6); + assert.equal(new Set(game.players.map(p => p.faction)).size, game.players.length); + assert.equal(game.players.filter(p => p.faction === native).length, 1); + assert.ok(game.players.every(p => p.faction === native || ['atreides', 'guild', 'emperor', 'fremen', 'beneGesserit', + ...(advanced ? [] : ['harkonnen'])].includes(p.faction))); + assert.ok(advanced || native !== 'ecaz' || game.players.length % 2 === 0, 'Basic odd-seat Ecaz remains source guarded.'); + assert.ok(!strongholds || advanced); + assert.ok(!game.homeworlds && !game.ecazTreachery && !game.advancedPreview && !game.semutaPreview && + !game.spiceBankerIncomePreview && !game.mentatQuestionPreview); + if (game.status === 'lobby') { + if (!!game.techTokens !== tech) game = applyAction(game, game.host, { type: 'techTokens', enabled: tech }); + if (!!game.strongholdCards !== strongholds) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: strongholds }); + game.discoveryEnabled = true; game.nexusCards ??= { cards: null, phase: null }; + assert.deepEqual(game.nexusCards, { cards: null, phase: null }); + for (const p of game.players) if (!p.ready) game = applyAction(game, p.id, { type: 'ready' }); + if (options.initial) game = initializeLeaderSkillsGameForAudit(game); + else { + const skill = options.skill ?? 'warmaster'; + const passive: LeaderSkillId[] = ['suk-graduate', 'sandmaster', 'killer-medic', 'prana-bindu-adept', 'master-of-assassins', 'swordmaster-of-ginaz'] + .filter(s => s !== skill) as LeaderSkillId[]; + const selected = game.players.map(p => p.faction === native ? skill : passive.shift()!); + const source = LEADER_SKILL_CARDS.map(c => c.id), remainder = source.filter(s => !selected.includes(s)); + const desired = selected.flatMap(s => [s, remainder.shift()!]); desired.push(...remainder); + game = shuffled(source, desired, () => initializeLeaderSkillsGameForAudit(game)); + } + } + assert.ok(game.status === 'setup' && game.turn === 1 && game.phase === 0 && + ['prediction', 'leaderSkills'].includes(game.setupStage!) && game.leaderSkills?.assignments.length === 0 && + game.players.every(p => !p.traitors.length && !p.traitorChoices.length), 'Only original unassigned setup may continue; starting hands stay held.'); + assert.ok(game.discoveryEnabled && game.discoveries && game.nexusCards?.cards); + assert.equal(!!game.techTokens, tech); assert.equal(!!game.strongholdCards, strongholds); + return game; +} +export function nextDiscoveryStandaloneNexusE3SkillsStep(game: Game): DiscoveryStandaloneNexusE3SkillsStep | null { + if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard && game.decision && + ['discoveryEntry', 'greatMakerRide', 'greatMakerVote', 'wormRide', 'wormPlacement', 'wormProtection', + 'advisor', 'intrusion', 'discoveryDiscard'].includes(game.decision.kind)) return nextClassicDiscoveryNexusStep(game); + if (game.decision?.kind === 'ecazBattleLead' && !game.response && !game.phaseOpening) return null; + if (game.decision?.kind === 'rihani' && !game.response && !game.phaseOpening) + return { actor: game.decision.player, action: { type: 'decision', event: game.decision.event, draw: false } }; + return game.players.some(p => p.faction === 'ecaz') + ? nextEcazNexusSkillsModulesStep(game) : nextMoritaniNexusSkillsModulesStep(game); +} +export function stepDiscoveryStandaloneNexusE3Skills(game: Game, step: DiscoveryStandaloneNexusE3SkillsStep, + actions?: DiscoveryStandaloneNexusE3SkillsStep[]): Game { + actions?.push(structuredClone(step)); + return withClassicDiscoveryNexusLottery(() => applyAction(game, step.actor, step.action)); +} +export function advanceDiscoveryStandaloneNexusE3Skills(game: Game, until: (state: Game) => boolean, + actions?: DiscoveryStandaloneNexusE3SkillsStep[]): Game { + for (let count = 0; count < 2400; count++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished'); + const next = nextDiscoveryStandaloneNexusE3SkillsStep(game); assert.ok(next, 'Actual owner must select the Occupy lead or seal a Battle Plan.'); + if (game.turn === 1 && next.action.type === 'stormDial') next.action.amount = 0; + game = stepDiscoveryStandaloneNexusE3Skills(game, next, actions); + } + throw Error('Original standalone E3 Discovery chronology did not reach its boundary.'); +} +export function completeDiscoveryStandaloneNexusE3SkillsSetup(state: Game, + options: DiscoveryStandaloneNexusE3SkillsOptions = {}, actions?: DiscoveryStandaloneNexusE3SkillsStep[]): Game { + let game = copy(state); + const owner = game.players.find(p => p.faction === 'ecaz' || p.faction === 'moritani')!.id; + for (let count = 0; game.status === 'setup' && count < 240; count++) { + if (game.setupStage !== 'leaderSkills') { + const next = nextDiscoveryStandaloneNexusE3SkillsStep(game); assert.ok(next); + if (game.setupStage === 'traitors' && next.actor === owner) { + const trained = new Set(game.leaderSkills!.assignments.filter(a => a.owner !== owner).map(a => a.leader)); + const target = discoveryStandaloneNexusE3SkillsPlayer(game, owner).traitorChoices.find(id => trained.has(id)); + if (target) next.action = { type: 'traitor', leader: target }; + } + game = stepDiscoveryStandaloneNexusE3Skills(game, next, actions); continue; + } + const actor = Object.keys(game.leaderSkills!.offers)[0], own = viewGame(game, actor).leaderSkills!; + const requested = actor === owner ? options.skill : undefined; + const legal = own.offer!.cards.filter(s => !own.unavailableSkills?.[s]); + const skill = requested ?? legal[0]; assert.ok(skill && legal.includes(skill)); + const discs = own.eligibleLeaders; + const leader = actor === owner && options.leader ? discs.find(l => l.id === options.leader) + : discoveryStandaloneNexusE3SkillsPlayer(game, actor).faction === 'guild' && skill === 'suk-graduate' + ? discs.find(l => l.name === 'Master Bewt') : discs[0]; + assert.ok(leader, 'Training uses a genuinely eligible owned native disc, never Duke or a foreign disc.'); + const step: DiscoveryStandaloneNexusE3SkillsStep = { actor, action: { type: 'leaderSkill', event: own.offer!.event, skill, leader: leader.id } }; + if (!options.initial && game.players.some(p => p.faction === 'moritani') && Object.keys(game.leaderSkills!.offers).length === 1) { + const opponent = (game.players.find(p => p.faction === 'guild') ?? game.players.find(p => p.id !== owner))!.id; + const target = actor === opponent ? leader.id : game.leaderSkills!.assignments.find(a => a.owner === opponent)?.leader; + assert.ok(target, 'The original opposing trained disc must exist before the pending Traitor deal.'); + const source = traitorDeck(game.players, false), desired = source.filter(id => id !== target); + desired.splice(game.players.findIndex(p => p.id === owner) * 4, 0, target); + actions?.push(structuredClone(step)); + // chooseLeaderSkill first shuffles its rejected card back into the remaining + // skill deck (old deck length draws); only THEN does advanceSetup deal Traitors. + game = shuffled(source, desired, () => applyAction(game, actor, step.action), game.leaderSkills!.deck.length); + } else game = stepDiscoveryStandaloneNexusE3Skills(game, step, actions); + } + assert.equal(game.status, 'playing'); return game; +} +function reserveBoard(game: Game, staging: string[]): void { + for (const p of game.players) { + p.reserves += Object.values(p.forces).reduce((sum, count) => sum + count, 0); p.forces = {}; + if (p.elites) { p.elites.reserves += Object.values(p.elites.forces).reduce((sum, count) => sum + count, 0); p.elites.forces = {}; } + if (p.advisors) p.advisors = {}; + } + staging.push('Conserved original board counters through OWN typed reserves; no held cards, wallets, earned custody, clocks, deaths or results assigned.'); +} +export function positionDiscoveryStandaloneNexusE3Skills(game: Game, actor: string, location: string, normal: number, + staging: string[]): void { + const p = discoveryStandaloneNexusE3SkillsPlayer(game, actor); + assert.ok(Number.isSafeInteger(normal) && normal >= 0 && p.reserves - (p.elites?.reserves ?? 0) >= normal); + p.reserves -= normal; p.forces[location] = (p.forces[location] ?? 0) + normal; + staging.push(`Conserved ${normal} original ordinary ${actor} counters: own reserves → ${location}.`); +} +export function assertDiscoveryStandaloneNexusE3SkillsCustody(game: Game): void { + validateLeaderSkills(game.leaderSkills!, game.players); validateDiscoveryState(game.discoveries!); + validateNexusCards(game.nexusCards!.cards!, game.players); + assert.deepEqual([...game.leaderSkills!.deck, ...Object.values(game.leaderSkills!.offers).flatMap(o => o.cards), + ...game.leaderSkills!.assignments.map(a => a.skill)].sort(), LEADER_SKILL_CARDS.map(c => c.id).sort()); + const nexus = game.nexusCards!.cards!; + assert.deepEqual([...nexus.deck, ...nexus.discard, ...Object.values(nexus.hands).filter((c): c is FactionId => c !== null)].sort(), [...NEXUS_FACTIONS].sort()); + assert.deepEqual([...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand), + ...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? [])].map(c => c.id).sort(), treacheryDeck(['ecaz']).map(c => c.id).sort()); + for (const p of game.players) { + assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum, count) => sum + count, 0), 20); + if (p.elites) assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((sum, count) => sum + count, 0), p.faction === 'emperor' ? 5 : 3); + } +} +/** Human endpoint: actual reveal then first END Mentat; free entry is before next Storm. */ +export function createDiscoveryStandaloneNexusE3SkillsFixture(options: DiscoveryStandaloneNexusE3SkillsOptions = {}): DiscoveryStandaloneNexusE3SkillsFixture { + const setup = initializeDiscoveryStandaloneNexusE3Skills(options), initial = options.initial ? copy(options.initial) : copy(setup); + const actions: DiscoveryStandaloneNexusE3SkillsStep[] = [], staging: string[] = []; + staging.push('Continuation uses the existing explicit scalar fixture lottery for original Storm/Spice outcomes; byte-array UUID entropy remains native. This is controlled source play, not natural random history.'); + if (!options.initial) staging.push('Controlled only the original physical all14 first shuffle; for Moritani, controlled the original pending Traitor shuffle before its real selection. No offered or held card is injected.'); + let game = completeDiscoveryStandaloneNexusE3SkillsSetup(setup, options, actions); + const trained = copy(game), owner = game.players.find(p => p.faction === 'ecaz' || p.faction === 'moritani')!.id; + const native = discoveryStandaloneNexusE3SkillsPlayer(game, owner).faction as DiscoveryStandaloneNexusE3SkillsNative; + const opponent = (game.players.find(p => p.faction === 'guild') ?? game.players.find(p => p.id !== owner))!.id; + const assignment = game.leaderSkills!.assignments.find(a => a.owner === owner)!; + const target = discoveryStandaloneNexusE3SkillsPlayer(game, owner).traitors.find(id => + game.leaderSkills!.assignments.some(a => a.owner === opponent && a.leader === id)) ?? null; + game = advanceDiscoveryStandaloneNexusE3Skills(game, g => g.phase === 1 && discoveryStandaloneNexusE3SkillsClean(g), actions); + const afterFirstStorm = copy(game); orderDiscoveryClassicNexusSkillsSpice(game, false, staging); + for (let count = 0; !game.discoveries!.tokens.some(t => t.face === 'cistern' && t.status === 'placed') && count < 120; count++) { + const next = nextDiscoveryStandaloneNexusE3SkillsStep(game); assert.ok(next); actions.push(structuredClone(next)); + game = withClassicDiscoveryNexusToken(game, 'cistern', () => applyAction(game, next.actor, next.action)); + } + game = advanceDiscoveryStandaloneNexusE3Skills(game, g => g.phase === 5 && discoveryStandaloneNexusE3SkillsClean(g), actions); + reserveBoard(game, staging); + const placement = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin']; + const source = `${placement.territory}:${placement.sector}`, wormSource = 'hagga_basin:12'; + const claim: StrongholdId = native === 'moritani' ? 'tueks_sietch' : 'arrakeen'; + positionDiscoveryStandaloneNexusE3Skills(game, owner, source, 3, staging); + positionDiscoveryStandaloneNexusE3Skills(game, owner, wormSource, 6, staging); + if (game.strongholdCards) positionDiscoveryStandaloneNexusE3Skills(game, owner, `${claim}:${territory(claim).sectors[0]}`, 1, staging); + game = advanceDiscoveryStandaloneNexusE3Skills(game, g => g.phase === 7 && discoveryStandaloneNexusE3SkillsClean(g), actions); + const collection = copy(game), token = game.discoveries!.tokens.find(t => t.face === 'cistern')!.id; + for (const reveal of [false, true]) game = stepDiscoveryStandaloneNexusE3Skills(game, { actor: owner, action: { type: 'discovery', token, reveal } }, actions); + game = advanceDiscoveryStandaloneNexusE3Skills(game, g => g.phase === 8 && discoveryStandaloneNexusE3SkillsClean(g), actions); + let firstMentat: DiscoveryStandaloneNexusE3SkillsTransition | undefined; + while (game.turn === 1) { + const before = copy(game), step = nextDiscoveryStandaloneNexusE3SkillsStep(game); assert.ok(step); + game = stepDiscoveryStandaloneNexusE3Skills(game, step, actions); + if (game.turn === 2) firstMentat = { before, step, after: copy(game) }; + } + assert.ok(firstMentat); assert.equal(game.decision?.kind, 'discoveryEntry'); assert.equal(game.decision!.player, owner); + return { initial, setup, trained, afterFirstStorm, collection, firstMentat, game, native, owner, opponent, + fremen: game.players.find(p => p.faction === 'fremen')?.id ?? null, leader: assignment.leader, skill: assignment.skill, + target, token, source, wormSource, claim, actions, staging }; +} +export function enterDiscoveryStandaloneNexusE3Skills(f: DiscoveryStandaloneNexusE3SkillsFixture, amount = 3): Game { + const game = copy(f.game), view = viewGame(game, f.owner); + const source = view.discoveryEntry!.sources.find(s => s.normal >= amount); assert.ok(source); + const action = discoveryEntryMoveAction(view, [{ source: source.source, normal: amount, elite: 0 }]); assert.ok(action); + return stepDiscoveryStandaloneNexusE3Skills(game, { actor: f.owner, action }, f.actions); +} +/** Original Maker deaths, all votes, actual typed reserve ride, settled alliance and BOTH piles close before drawing. */ +export function closeDiscoveryStandaloneNexusE3Skills(f: DiscoveryStandaloneNexusE3SkillsFixture, + target: FactionId | undefined = f.native): DiscoveryStandaloneNexusE3SkillsClosing { + assert.ok(f.game.players.length >= 3, 'A qualified native draw needs two other real alliance seats.'); + let game = enterDiscoveryStandaloneNexusE3Skills(f); const entered = copy(game); + game = advanceDiscoveryStandaloneNexusE3Skills(game, g => g.phase === 1 && discoveryStandaloneNexusE3SkillsClean(g), f.actions); + orderDiscoveryClassicNexusSkillsSpice(game, true, f.staging); + game = advanceDiscoveryStandaloneNexusE3Skills(game, g => g.greatMaker?.stage === 'vote', f.actions); + const vote = copy(game); + while (game.decision?.kind === 'greatMakerVote') game = stepDiscoveryStandaloneNexusE3Skills(game, + { actor: game.decision.player, action: { type: 'decision', event: game.decision.event, yes: true } }, f.actions); + let ride: DiscoveryStandaloneNexusE3SkillsTransition | null = null; + if (game.decision?.kind === 'greatMakerRide') { + const before = copy(game), actor = game.decision.player; + const action = greatMakerRideAction(viewGame(game, actor), 'polar_sink', 0, 2, game.advanced ? 1 : 0); assert.ok(action); + const step = { actor, action }; game = stepDiscoveryStandaloneNexusE3Skills(game, step, f.actions); + ride = { before, step, after: copy(game) }; + } + game = advanceDiscoveryStandaloneNexusE3Skills(game, g => g.nexus && !g.spiceWindow && !g.spiceResolution && discoveryStandaloneNexusE3SkillsClean(g), f.actions); + const others = game.players.filter(p => p.id !== f.owner); + for (const [actor, target] of [[others[0].id, others[1].id], [others[1].id, others[0].id]]) + game = stepDiscoveryStandaloneNexusE3Skills(game, { actor, action: { type: 'alliance', target } }, f.actions); + const alliance = copy(game); + game = advanceDiscoveryStandaloneNexusE3Skills(game, g => g.nexusCards?.phase?.stage === 'drawing' && discoveryStandaloneNexusE3SkillsClean(g), f.actions); + const cards = game.nexusCards!.cards!; + if (target) { + const index = cards.deck.indexOf(target); assert.ok(index >= 0); + cards.deck.unshift(cards.deck.splice(index, 1)[0]); + f.staging.push(`Conserved UNPLAYED original ${target} Nexus singleton ordered before actual native closing choice; no held-card injection.`); + } + const before = copy(game), step: DiscoveryStandaloneNexusE3SkillsStep = { actor: f.owner, + action: { type: 'nexusCardChoice', turn: game.turn, card: cards.hands[f.owner], choice: 'draw', ownRedraws: 0 } }; + game = stepDiscoveryStandaloneNexusE3Skills(game, step, f.actions); + return { entered, vote, ride, alliance, draw: { before, step, after: copy(game) }, game }; +} +export function cunningDiscoveryStandaloneNexusE3Skills(f: DiscoveryStandaloneNexusE3SkillsFixture, + state: Game): DiscoveryStandaloneNexusE3SkillsTransition { + let game = copy(state); + if (f.native === 'ecaz') { + game = advanceDiscoveryStandaloneNexusE3Skills(game, g => g.phase === 5 && discoveryStandaloneNexusE3SkillsClean(g), f.actions); + reserveBoard(game, f.staging); + if (game.strongholdCards) + positionDiscoveryStandaloneNexusE3Skills(game, f.owner, `${f.claim}:${territory(f.claim).sectors[0]}`, 1, f.staging); + // With no physical clash, the original engine skips Battle altogether. + // Keep a real chooser pending, but do not open a battle before quiet Cunning. + for (const actor of [f.owner, f.opponent]) + positionDiscoveryStandaloneNexusE3Skills(game, actor, 'cistern:0', 1, f.staging); + } + const phase = f.native === 'ecaz' ? 6 : 8; + game = advanceDiscoveryStandaloneNexusE3Skills(game, g => g.phase === phase && + (f.native === 'ecaz' ? discoveryStandaloneNexusE3SkillsClean(g) : g.decision?.kind === 'moritaniPlacement') && !g.battle, f.actions); + const before = copy(game), step = f.native === 'ecaz' ? ecazNexusSkillsModulesCunningRequest(game, f.owner) + : { actor: f.owner, action: moritaniNexusSkillsModulesRequest(game, f.owner, 'robbery', 'red_chasm') }; + game = stepDiscoveryStandaloneNexusE3Skills(game, step, f.actions); + game = advanceDiscoveryStandaloneNexusE3Skills(game, g => !g.response && !g.phaseOpening && !g.pendingKarama, f.actions); + return { before, step, after: game }; +} +/** A requested face reaches the hand ONLY through an actual original auction and payment. */ +function buyWorthless(f: DiscoveryStandaloneNexusE3SkillsFixture, state: Game, actor: string, + enemy?: string): { game: Game; card: string; enemyCard?: string } { + let game = advanceDiscoveryStandaloneNexusE3Skills(state, g => g.phase === 2 && discoveryStandaloneNexusE3SkillsClean(g), f.actions); + const wanted: { actor: string; card: string }[] = [], front: Game['deck'] = []; + for (const id of [actor, ...(enemy ? [enemy] : [])]) { + const held = discoveryStandaloneNexusE3SkillsPlayer(game, id).hand.find(c => c.kind === 'worthless'); + if (held) { wanted.push({ actor: id, card: held.id }); continue; } + const index = game.deck.findIndex(c => c.kind === 'worthless'); assert.ok(index >= 0); + const card = game.deck.splice(index, 1)[0]; front.push(card); wanted.push({ actor: id, card: card.id }); + f.staging.push(`Conserved original UNPLAYED Ecaz33 ${card.id} ordered before auction; actual bidder and payment alone acquire it.`); + } + game.deck.unshift(...front); + for (let count = 0; count < 320; count++) { + if (wanted.every(w => discoveryStandaloneNexusE3SkillsPlayer(game, w.actor).hand.some(c => c.id === w.card))) + return { game, card: wanted[0].card, enemyCard: wanted[1]?.card }; + const next = nextDiscoveryStandaloneNexusE3SkillsStep(game); assert.ok(next); + if (game.phase === 3 && !game.response && !game.phaseOpening && !game.decision && game.auction && !game.auction.bid) { + const buyer = wanted.find(w => w.card === game.auction!.cards[game.auction!.index]?.id && w.actor === game.auction!.active); + if (buyer) next.action = { type: 'bid', amount: 1 }; + } + game = stepDiscoveryStandaloneNexusE3Skills(game, next, f.actions); + } + throw Error('Actual auction did not deliver its physical requested faces.'); +} +/** For Ecaz, occupy requests a later REAL Nexus alliance and returns the mandatory lead choice. + * No alliance is assigned and no quiet Duke custody is confused with native training. */ +export function openDiscoveryStandaloneNexusE3SkillsBattle(f: DiscoveryStandaloneNexusE3SkillsFixture, state: Game, + options: { occupy?: boolean; lead?: 'native' | 'ally'; normalCall?: boolean } = {}): DiscoveryStandaloneNexusE3SkillsBattle { + let game = copy(state), ally: string | null = null; + if (options.occupy) { + assert.equal(f.native, 'ecaz'); assert.ok(game.advanced && game.players.length >= 3); + // Quiet Cunning left an actual, unopened clash. Resolve it with original + // discs and zero-dial sealed plans before the later Occupy Nexus; never + // erase its chooser or advance a fabricated Battle result. + if (game.phase === 6 && !game.battle) { + const chooser = game.active!; + game = stepDiscoveryStandaloneNexusE3Skills(game, { actor: chooser, + action: { type: 'chooseBattle', territory: 'cistern', target: chooser === f.owner ? f.opponent : f.owner } }, f.actions); + for (let count = 0; count < 160; count++) { + const next = nextDiscoveryStandaloneNexusE3SkillsStep(game); + if (!next) break; + game = stepDiscoveryStandaloneNexusE3Skills(game, next, f.actions); + } + assert.ok(game.battle && !game.battle.revealed && discoveryStandaloneNexusE3SkillsClean(game)); + const trained = new Set(game.leaderSkills!.assignments.map(a => a.leader)); + const defender = discoveryStandaloneNexusE3SkillsPlayer(game, f.opponent).leaders + .filter(l => !l.dead && !trained.has(l.id)).sort((a, b) => a.strength - b.strength)[0]; + assert.ok(defender); + game = revealDiscoveryNativeE3SkillsPlans(game, [ + { actor: f.owner, action: { type: 'battlePlan', leader: f.leader, dial: 0, support: 0 } }, + { actor: f.opponent, action: { type: 'battlePlan', leader: defender.id, dial: 0, support: 0 } }, + ], f.actions); + game = finishDiscoveryStandaloneNexusE3SkillsBattle(game, f.actions); + } + const turn = game.turn; + game = advanceDiscoveryStandaloneNexusE3Skills(game, g => g.turn > turn && g.phase === 1 && discoveryStandaloneNexusE3SkillsClean(g), f.actions); + const front: Game['spiceDeck'] = []; + for (const kind of ['worm', 'land', 'land']) { + const index = game.spiceDeck.findIndex(c => kind === 'worm' ? 'worm' in c && !c.greatMaker : 'territory' in c && !c.discovery); + assert.ok(index >= 0); front.push(game.spiceDeck.splice(index, 1)[0]); + } + game.spiceDeck.unshift(...front); f.staging.push('Conserved original unplayed ordinary worm and both replacement lands for a later actual Occupy alliance.'); + game = advanceDiscoveryStandaloneNexusE3Skills(game, g => g.nexus && !g.spiceWindow && !g.spiceResolution && discoveryStandaloneNexusE3SkillsClean(g), f.actions); + ally = game.players.find(p => p.id !== f.owner && p.id !== f.opponent)!.id; + if (discoveryStandaloneNexusE3SkillsPlayer(game, ally).ally) + game = stepDiscoveryStandaloneNexusE3Skills(game, { actor: ally, action: { type: 'alliance', target: null } }, f.actions); + for (const [actor, target] of [[f.owner, ally], [ally, f.owner]]) + game = stepDiscoveryStandaloneNexusE3Skills(game, { actor, action: { type: 'alliance', target } }, f.actions); + } + const actor = options.lead === 'ally' ? ally! : f.owner; + const bought = buyWorthless(f, game, actor, f.native === 'moritani' ? f.opponent : undefined); game = bought.game; + game = advanceDiscoveryStandaloneNexusE3Skills(game, g => g.phase === 5 && discoveryStandaloneNexusE3SkillsClean(g), f.actions); + reserveBoard(game, f.staging); + const site = f.native === 'moritani' && game.strongholdCards ? 'tueks_sietch' : 'cistern'; + const location = `${site}:${site === 'cistern' ? 0 : territory(site).sectors[0]}`; + for (const id of [f.owner, f.opponent, ...(ally ? [ally] : [])]) + positionDiscoveryStandaloneNexusE3Skills(game, id, location, ally && id === f.owner ? 3 : 6, f.staging); + game = advanceDiscoveryStandaloneNexusE3Skills(game, g => g.phase === 6 && discoveryStandaloneNexusE3SkillsClean(g), f.actions); + if (f.native === 'ecaz' && !ally && viewGame(game, f.owner).nexusEcazDuke?.blocked === null) + game = stepDiscoveryStandaloneNexusE3Skills(game, ecazNexusSkillsModulesCunningRequest(game, f.owner), f.actions); + const beforeBattle = copy(game); + const chooser = game.active!; assert.ok([f.owner, f.opponent, ally].includes(chooser)); + const choice = viewGame(game, chooser).battleChoices.find(c => c.chooser === chooser && c.territory === site && + (c.attacker === f.opponent || c.defender === f.opponent)); + assert.ok(choice, 'Choose the actual unresolved physical coalition battle at this site.'); + const target = chooser === f.opponent + ? choice.attacker === chooser ? choice.defender : choice.attacker + : f.opponent; + game = stepDiscoveryStandaloneNexusE3Skills(game, { actor: chooser, + action: { type: 'chooseBattle', territory: site, target } }, f.actions); + let leadChoice: DiscoveryStandaloneNexusE3SkillsTransition | null = null; + if (ally) { + game = advanceDiscoveryStandaloneNexusE3Skills(game, g => g.decision?.kind === 'ecazBattleLead' && !g.response && !g.phaseOpening, f.actions); + const decision = game.decision; assert.ok(decision?.kind === 'ecazBattleLead'); + const before = copy(game), step: DiscoveryStandaloneNexusE3SkillsStep = { actor: f.owner, + action: { type: 'decision', event: decision.event, lead: actor } }; + game = stepDiscoveryStandaloneNexusE3Skills(game, step, f.actions); leadChoice = { before, step, after: copy(game) }; + } + const trained = new Set(game.leaderSkills!.assignments.map(a => a.leader)); + const own = discoveryStandaloneNexusE3SkillsPlayer(game, actor); + const enemy = discoveryStandaloneNexusE3SkillsPlayer(game, f.opponent); + const leader = f.native === 'ecaz' ? game.leaderSkills!.assignments.find(a => a.owner === actor)!.leader + : own.leaders.filter(l => !l.dead && !trained.has(l.id)).sort((a, b) => a.strength - b.strength)[0].id; + const enemyLeader = options.normalCall ? own.traitors.find(id => enemy.leaders.some(l => l.id === id && !l.dead)) + : enemy.leaders.filter(l => !l.dead && !trained.has(l.id) && l.id !== f.target).sort((a, b) => f.native === 'ecaz' ? a.strength - b.strength : b.strength - a.strength)[0]?.id; + assert.ok(enemyLeader, 'Normal-call case requires an actually held opposing Traitor, never a transfer.'); + for (let count = 0; count < 160; count++) { + const next = nextDiscoveryStandaloneNexusE3SkillsStep(game); + if (!next) break; + if (next.action.type === 'leaderSkillVisibility') { + const planned = next.actor === actor ? leader : next.actor === f.opponent ? enemyLeader : null; + next.action.hide = game.leaderSkills!.assignments.some(a => a.owner === next.actor && a.leader === planned); + } + game = stepDiscoveryStandaloneNexusE3Skills(game, next, f.actions); + } + assert.ok(game.battle && !game.battle.revealed && discoveryStandaloneNexusE3SkillsClean(game)); + const dial = f.native === 'ecaz' ? 1 : 4; + const fixedDial = viewGame(game, actor).battle!.ecazOccupy?.profile?.fixedEcazDial ?? 0; + return { beforeBattle, leadChoice, game, actor, opponent: f.opponent, leader, enemyLeader, location, card: bought.card, enemyCard: bought.enemyCard, + target: f.target, plans: [ + { actor, action: { type: 'battlePlan', leader, dial: fixedDial + 1, + support: game.advanced && discoveryStandaloneNexusE3SkillsPlayer(game, ally ?? actor).faction !== 'fremen' ? 1 : 0, weapon: bought.card } }, + { actor: f.opponent, action: { type: 'battlePlan', leader: enemyLeader, dial, support: game.advanced && enemy.faction !== 'fremen' ? dial : 0, weapon: bought.enemyCard } }, + ] }; +} +export function revealDiscoveryStandaloneNexusE3SkillsBattle(battle: DiscoveryStandaloneNexusE3SkillsBattle, + actions?: DiscoveryStandaloneNexusE3SkillsStep[]): Game { + return revealDiscoveryNativeE3SkillsPlans(copy(battle.game), battle.plans, actions); +} +export function settleDiscoveryStandaloneNexusE3SkillsBattle(state: Game, callers: readonly string[] = [], + actions?: DiscoveryStandaloneNexusE3SkillsStep[]): DiscoveryStandaloneNexusE3SkillsBoundary { + let game = copy(state); + const choices: Partial['kind'], DiscoveryStandaloneNexusE3SkillsBoundary['kind']>> = { + battleLosses: 'losses', sukRescue: 'rescue', moritaniAssassinate: 'assassination', battleCards: 'cards', techToken: 'tech', + }; + for (let count = 0; count < 240; count++) { + const kind = game.decision && choices[game.decision.kind]; + if (kind && !game.response && !game.phaseOpening) return { game, kind }; + if (!game.battle && discoveryStandaloneNexusE3SkillsClean(game)) return { game, kind: 'settled' }; + const next = nextDiscoveryStandaloneNexusE3SkillsStep(game); assert.ok(next); + if (next.action.type === 'traitorCall') next.action.call = callers.includes(next.actor); + game = stepDiscoveryStandaloneNexusE3Skills(game, next, actions); + } + throw Error('Original human settlement boundary did not open.'); +} +export function finishDiscoveryStandaloneNexusE3SkillsBattle(state: Game, + actions?: DiscoveryStandaloneNexusE3SkillsStep[]): Game { + return advanceDiscoveryStandaloneNexusE3Skills(copy(state), g => !g.battle && discoveryStandaloneNexusE3SkillsClean(g), actions); +} diff --git a/tools/faction-games.ts b/tools/faction-games.ts index 9bf7c5b1..4889d426 100644 --- a/tools/faction-games.ts +++ b/tools/faction-games.ts @@ -54,7 +54,9 @@ type Profile = 'base' | 'choam' | 'ecaz' | 'ecaz-treachery' | 'ecaz-occupy' | 'm | 'native-discovery-skills' | 'native-discovery-skills-tech' | 'native-discovery-skills-stronghold' | 'native-discovery-skills-stronghold-tech' | 'discovery-nexus' | 'discovery-nexus-tech' | 'discovery-nexus-stronghold' | 'discovery-nexus-stronghold-tech' | 'discovery-nexus-skills' | 'discovery-nexus-skills-tech' | 'discovery-nexus-skills-stronghold' | 'discovery-nexus-skills-stronghold-tech' - | 'paired-discovery-nexus' | 'paired-discovery-nexus-tech' | 'paired-discovery-nexus-stronghold' | 'paired-discovery-nexus-stronghold-tech'; + | 'paired-discovery-nexus' | 'paired-discovery-nexus-tech' | 'paired-discovery-nexus-stronghold' | 'paired-discovery-nexus-stronghold-tech' + | 'paired-discovery-nexus-skills' | 'paired-discovery-nexus-skills-tech' | 'paired-discovery-nexus-skills-stronghold' | 'paired-discovery-nexus-skills-stronghold-tech' + | 'e3-discovery-nexus-skills' | 'e3-discovery-nexus-skills-tech' | 'e3-discovery-nexus-skills-stronghold' | 'e3-discovery-nexus-skills-stronghold-tech'; 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' || @@ -72,6 +74,11 @@ const discoveryNexusSkillsProfile = (profile: string) => profile === 'discovery- const pairedDiscoveryNexusSampleProfile = (profile: string) => profile === 'paired-discovery-nexus' || profile === 'paired-discovery-nexus-tech' || profile === 'paired-discovery-nexus-stronghold' || profile === 'paired-discovery-nexus-stronghold-tech'; +const nativeDiscoveryNexusSkillsProfile = (profile: string) => + profile === 'paired-discovery-nexus-skills' || profile === 'paired-discovery-nexus-skills-tech' || + 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'; type Rules = 'basic' | 'advanced'; type Scenario = { @@ -200,19 +207,19 @@ const CLASSIC_NEXUS_MODULE_SCENARIOS: readonly Scenario[] = const discoveryNexusProfile = (profile: string) => profile === 'discovery-nexus' || profile === 'discovery-nexus-tech' || profile === 'discovery-nexus-stronghold' || profile === 'discovery-nexus-stronghold-tech' || discoveryNexusSkillsProfile(profile) || - pairedDiscoveryNexusSampleProfile(profile); + pairedDiscoveryNexusSampleProfile(profile) || nativeDiscoveryNexusSkillsProfile(profile); const nexusModuleProfile = (profile: string) => profile === 'nexus-tech' || profile === 'nexus-stronghold' || profile === 'nexus-stronghold-tech' || discoveryNexusProfile(profile) || pairedNexusModuleProfile(profile) || nexusSkillsModuleProfile(profile) || pairedNexusSkillsModuleProfile(profile) || e3NexusSkillsProfile(profile); const nexusTechProfile = (profile: string) => - ((discoveryNexusSkillsProfile(profile) || pairedDiscoveryNexusSampleProfile(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' || profile === 'nexus-skills-tech' || profile === 'nexus-skills-stronghold-tech' || 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) => - ((discoveryNexusSkillsProfile(profile) || pairedDiscoveryNexusSampleProfile(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' || profile === 'nexus-skills-stronghold' || profile === 'nexus-skills-stronghold-tech' || @@ -463,6 +470,18 @@ const PAIRED_DISCOVERY_NEXUS_SCENARIOS: readonly Scenario[] = (!profile.includes('stronghold') || scenario.rules === 'advanced') && (!profile.endsWith('tech') || scenario.roster.length >= 3)) .map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 5200 + band * 100 + index }))); +const E3_DISCOVERY_NEXUS_SKILLS_BASE_SCENARIOS = E3_NEXUS_SKILLS_SCENARIOS + .filter(scenario => scenario.profile === 'e3-nexus-skills'); +const NATIVE_DISCOVERY_NEXUS_SKILLS_SCENARIOS: readonly Scenario[] = + (['paired-discovery-nexus-skills', 'paired-discovery-nexus-skills-tech', + 'paired-discovery-nexus-skills-stronghold', 'paired-discovery-nexus-skills-stronghold-tech', + 'e3-discovery-nexus-skills', 'e3-discovery-nexus-skills-tech', + 'e3-discovery-nexus-skills-stronghold', 'e3-discovery-nexus-skills-stronghold-tech'] as const) + .flatMap((profile, band) => (profile.startsWith('paired') + ? PAIRED_NEXUS_SKILLS_SCENARIOS : E3_DISCOVERY_NEXUS_SKILLS_BASE_SCENARIOS) + .filter(scenario => (!profile.includes('stronghold') || scenario.rules === 'advanced') && + (!profile.endsWith('tech') || scenario.roster.length >= 3)) + .map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 5600 + band * 100 + index }))); const STRONGHOLD_FACTIONS_ROSTER: readonly FactionId[] = [ 'ixians', 'choam', 'emperor', 'fremen', 'harkonnen', 'beneGesserit', ]; @@ -541,7 +560,7 @@ 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) => 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 skillProfile = (profile: string) => 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'; const assassinationScenario = (scenario: Scenario) => scenario.rules === 'advanced' && @@ -587,6 +606,7 @@ function usage() { '[--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] ' + '[--rules both|basic|advanced] [--players all|2|3|4|5|6] [--max-actions POSITIVE] ' + '[--resume FAILED_GAME.json]\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' + 'Discovery Nexus combinations: discovery-nexus-skills[-tech|-stronghold|-stronghold-tech] uses original classic all14 training; paired-discovery-nexus[-tech|-stronghold|-stronghold-tech] uses both native E1 or E2 factions without Skills. Basic/Advanced2–6, Tech3+, Strongholds Advanced, no Homeworlds/variants/previews; old profiles/defaults/seeds unchanged.\n' + 'Native skill Discovery profiles: native-discovery-skills, native-discovery-skills-tech, native-discovery-skills-stronghold, native-discovery-skills-stronghold-tech; original native skill families/rosters and setup, optional Tech3+ and Advanced Strongholds, no Nexus/Homeworlds/variants/previews. Earlier profiles/defaults/seeds remain unchanged.\n' + 'Advanced Discovery Stronghold profiles: discovery-stronghold, discovery-stronghold-tech, discovery-skills-stronghold, discovery-skills-stronghold-tech; native or classic-skill original setup, optional Tech3+, original held claims, no Nexus/Homeworlds/variants/previews. Earlier defaults and seeds are unchanged.\n' + @@ -641,7 +661,7 @@ function positive(value: string | undefined) { function scenarioName(scenario: Scenario) { if (nativeDiscoverySkillsProfile(scenario.profile) || discoveryNexusSkillsProfile(scenario.profile) || - pairedDiscoveryNexusSampleProfile(scenario.profile)) + pairedDiscoveryNexusSampleProfile(scenario.profile) || nativeDiscoveryNexusSkillsProfile(scenario.profile)) return `${scenario.profile}-${scenario.expansions.join('-')}-${scenario.roster.join('-')}-${scenario.roster.length}-${scenario.rules}`; if (discoveryProfile(scenario.profile)) return `${scenario.profile}-${scenario.expansions[0] ?? 'classic'}-${scenario.roster[0]}-${scenario.roster.length}-${scenario.rules}`; @@ -808,7 +828,7 @@ function resumedGame(path: string) { game.mentatQuestionPreview ) throw new Error('--resume sample scenarios exclude unsupported optional modules.'); - const scenario = [...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].find( + const scenario = [...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].find( (candidate) => (nativeDiscoverySkillsProfile(candidate.profile) || discoveryProfile(candidate.profile) || discoverySkillsProfile(candidate.profile) || discoveryNexusProfile(candidate.profile)) === discovery && (candidate.profile === 'homeworld' || candidate.profile === 'homeworld-occupation' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.homeworlds && @@ -819,7 +839,7 @@ function resumedGame(path: string) { (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' || ((nativeDiscoverySkillsProfile(candidate.profile) || nativeTechProfile(candidate.profile) || discoveryProfile(candidate.profile)) && candidate.roster.includes('ecaz'))) === !!game.ecazOccupyPreview && + (candidate.profile === 'ecaz-occupy' || ((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) === @@ -1039,7 +1059,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 = discoveryNexusSkillsProfile(profile) ? DISCOVERY_NEXUS_SKILLS_SCENARIOS + const requestedSamples = nativeDiscoveryNexusSkillsProfile(profile) ? NATIVE_DISCOVERY_NEXUS_SKILLS_SCENARIOS + : discoveryNexusSkillsProfile(profile) ? DISCOVERY_NEXUS_SKILLS_SCENARIOS : pairedDiscoveryNexusSampleProfile(profile) ? PAIRED_DISCOVERY_NEXUS_SCENARIOS : nativeDiscoverySkillsProfile(profile) ? NATIVE_DISCOVERY_SKILLS_SCENARIOS : discoveryStrongholdProfile(profile) ? DISCOVERY_STRONGHOLD_SCENARIOS diff --git a/tools/start-prototype.ts b/tools/start-prototype.ts index 65d0abde..f5671136 100644 --- a/tools/start-prototype.ts +++ b/tools/start-prototype.ts @@ -42,7 +42,7 @@ 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 unused original Tech3–6 and Advanced Strongholds2–6. Original decks/cache, HMS/Face Dancers, typed entry, revealed-only nested No-Fields, stash limits, end-Mentat held claims and native outcomes remain. Advanced Moritani excludes Harkonnen. Skills/HW/Nexus, paired/mixed E3, contested/shared-payer rulings, public starts and played-game conversion stay separate.', - '\nLeader-skills --discoveries explicitly composes fresh supported classic or native original family decks/all14 Skills with Discovery7/8 at2–6 seats Basic/Advanced under each existing roster predicate. Unused original Tech requires3–6; original Strongholds requires Advanced2–6. Classic setups additionally accept --nexus-cards, retaining all12 physical Nexus cards, actual Great Maker votes/typed rides and the settled-alliance end-Spice closing deal. Original offers, starting hands, claims, rescue and source-clear invoices remain. Native Skills/Nexus/Discovery, HW/variants, Banker/Mentat and unrelated previews, pending rulings, public starts and save conversion stay separate.', + '\nLeader-skills --discoveries explicitly composes fresh supported classic or native original family decks/all14 Skills with Discovery7/8 at2–6 seats Basic/Advanced under each existing roster predicate. Unused original Tech requires3–6; original Strongholds requires Advanced2–6. --nexus-cards additionally retains all12 with classic factions, both original paired E1/E2 natives, or standalone Ecaz OR Moritani with classics. Actual offered training/starting hands, Maker votes/typed rides, both Advanced piles and settled-alliance end-Spice closing deals remain. Signed native pairs, trained rescue/cleanup/Tech/Face Dance and mandatory owner-labelled Ecaz or original Moritani consumers keep their sources. Mixed/E3 pairs, HW/variants, Banker/Mentat previews, shared Duke/borrowed Smuggler/pair companion/native return and other pending rulings, public starts and save conversion remain guarded.', '\nNexus --discoveries explicitly composes fresh classic/base33 OR both native Ixians+Tleilaxu/Ix47 OR CHOAM+Richese/CHOAM35+cache with all12 Nexus and original Discovery7/8 at2–6 seats Basic/Advanced. 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. Native Skills, HW, E3/mixed families, mixed-module Fremen Betrayal, pending rulings, public activation and save conversion stay guarded.', ); return;