diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 5e0447ef..b8d3be44 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,41 @@ open mode gates. ## Current checkpoint and work +**Standalone E3 Nexus / Skills / selected Tech/Strongholds — bounded Development Verified:** +native Ecaz OR Moritani plus classics, exact ecaz33/all14/all12, +Basic/Advanced2..6; original Tech3+ and/or Advanced Strongholds2+, both3+. +Fifty-four original setup programs/four legal policies and26 immutable +prerequisite rejects pass; new24/affected224 cases, types/lint/build pass. +All54 genuine games finish24,845 accepted actions/no rejection,648 JSON +continuations/28 closing choices/five coalition leads on unchanged tree +`ceebd866c4de7dfd122c0ddb9754a730b7bbdd93340afddffbd26ec157561816`. +Three preserved Advanced Occupy captures finish918/752/21 more actions at +their original scenario seeds; resume restarts, not reconstructs, the stream. +The mandatory coalition guard is connected; no skipped fight or replaced +saved position. Meaningful regressions exposed and repaired wrong-army Suk rescue and +combined-dial Diplomat retreat. Selected-plan skills retain separate physical +armies; Ecaz rescues its own fixed casualties, never the ally's variable losses. + +Original backed-up CLI hands/offers/IDs remain authoritative. Human390px +**YZLNTNEZ v23/Collection** spends living-Duke Cunning, wins7–5 with normal +Warmaster/held Shield against poison, releases Duke, retains five assignments, +receives original Emperor Production and city2 (12→14). +**R2S5UX5U v27/Mentat** preserves normal Guild Suk, publicly kills unused +Master Bewt3/returns its skill/pays bounty11→14 after loss, retains original +cleanup/Tech order and one private replacement, then places supply Robbery +with Cunning at Red Chasm. No eligible human Karama counter; source programs +separately exercise cancellation/stacking. +**L7C8HTQT v23/Collection** uses real Ecaz/Atreides alliance, held Arrakeen +bank2/own0, trained Sanya4/dial5/support2 and independent fixed3 rescue: +Ecaz17 reserve/0 Tanks/3 board; ally16 reserve/2 Tanks/2 board. Emperor Axlotl +transfers to Ecaz after cleanup; allied Production stays Atreides; city2 pays +12→14. Refreshed controls/force/Terror inspectors show no root390px overflow. +Only dedicated QA rooms change; conserved positions are labelled. +Advanced Harkonnen, E3 pairs/mixed families/HW/independent cards/previews, +shared Duke training, enhanced Terror relocation/HMS/Grumman, pending rulings, +public starts, save conversion and full/deployed acceptance stay guarded. +[Canonical scope and evidence](NEXUS_CARD_RULES.md#standalone-e3-nexus-skills-and-original-components--5-october-2026). + **Paired checkpoint verified and published:** exact code **8b6bf385601dd878e81ca11d47076aa93e7cb806** is pushed. [CI37211854749](https://github.com/Quintine/Dune/actions/runs/37211854749) / diff --git a/docs/ECAZ_OCCUPY_RULES.md b/docs/ECAZ_OCCUPY_RULES.md index 765db045..109d1830 100644 --- a/docs/ECAZ_OCCUPY_RULES.md +++ b/docs/ECAZ_OCCUPY_RULES.md @@ -2,6 +2,35 @@ Historical primary-source audit, 2026-09-07, corrected after independent verification. The old publisher/designer casualty-rounding conflict remains unresolved as a publisher correction and for odd Basic counts. Its earlier false attribution to Jack Reda is still withdrawn. The later user-authorized Advanced source below selects explicit Advanced arithmetic, and the Basic even-force composition selects E/2 for even counts; neither retroactively certifies that attribution. +## Standalone Nexus / Skills coalition — 5 October 2026 + +The fresh standalone Ecaz/classic skill-Nexus profile now connects mandatory +Advanced coalition battles, including selected original Tech/Strongholds. +The chosen faction supplies the plan and its skill; the ally's training is +not lent to that plan. The existing selected-lead and owner-labelled army +contract remains. Ecaz-led Suk redirects its own fixed casualties after the +ally's original variable casualties. Diplomat retreats only the selected +faction's undialed own counters: Ecaz uses the fixed Ecaz commitment, while an +ally lead uses the variable dial. Cancellation restores the selected lead's +ordinary own-pool calculation. These owner-pool compositions are explicit +implementation inferences from the card and selected-plan contract, not a +publisher combined-Skills ruling. Shared-Duke training and other unresolved +effects are not enabled. + +Three genuine Advanced captures stopped at the original module guard; each +now finishes without rejected actions or skipped battles (918/752/21 more, +original scenario seeds; resume does not reconstruct the earlier stream). +Meaningful regressions reproduced wrong-army Suk rescue and missing Diplomat +retreat before repair, then preserve raw support, separate losses and JSON choices. +The [coalition regressions](../tests/ecaz-nexus-skills-occupy-runtime.test.ts) +retain the separate native source pools and actual rescue/retreat decisions. +Human390px L7C8HTQT uses an actual Nexus alliance: Ecaz5/ally4, +total dial5/fixed3/variable2, held Arrakeen bank2/own0, trained Sanya4. It wins +9–6, loses ally2, rescues Ecaz3 into one kept/two reserves, then receives +original Emperor Axlotl after card cleanup and separate city2 income. +[Exact standalone source/program evidence](NEXUS_CARD_RULES.md#standalone-e3-nexus-skills-and-original-components--5-october-2026). +Basic's separate even-force profile and odd-count ruling below are unchanged. + ## Basic even-force composition — 4 October 2026 Fresh local `ecaz-occupy` now admits Basic as well as Advanced. Basic uses the diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 6e5751ec..1db3491d 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,44 @@ # Dune implementation status +## 5 October 2026 — standalone E3 Nexus, Skills and original components + +Original `leader-skills --nexus-cards` now admits Ecaz OR Moritani/classic +ecaz33/all14/all12, Basic/Advanced2..6, preserving original Tech3+ and/or +Advanced Strongholds2+ (both3+). Two bounded owners supplied connected native +rule fixtures; parent integrated admission, engine, original entry/samples, +reference/guidance, mandatory Advanced Occupy and human proof. + +Fifty-four original setups/four legal policies and26 immutable prerequisite +rejects pass. New24/affected224 cases, types/lint/build pass. Fifty-four genuine +2..6 games complete24,845 accepted/no rejection,648 JSON continuations,28 +closing choices/five original coalition leads on unchanged tree +`ceebd866c4de7dfd122c0ddb9754a730b7bbdd93340afddffbd26ec157561816`. +Three preserved captures finish918/752/21 more actions at their original +scenario seeds; continuation restarts that seed, not the pre-capture stream. +No skipped fight, reassigned outcome, replaced position or guard suppression. +Meaningful coalition cases reproduced wrong-army Suk rescue and missing +Diplomat retreat, then verify separate physical commitments and JSON choices. +Chosen-plan/own-force skill composition is explicitly inferred, not a new FAQ. + +Original backed-up CLI offers/hands/IDs stay authoritative. Human390px +YZLNTNEZ v23 uses living-Duke Cunning/normal Warmaster/held poison protection, +wins7–5 and releases Duke before original Tech/city income. R2S5UX5U v27 +keeps normal Guild Suk, kills unused Master Bewt3 after loss, returns its skill, +pays bounty11→14, preserves winner cleanup/Tech and one private replacement, +then places supply Robbery Cunning at Red Chasm. L7C8HTQT v23 uses an actual +Nexus alliance, trained Sanya4/dial5/support2 with bank2/own0, separate ally2 +losses and fixed Ecaz3 rescue:17 reserve/0 Tanks/3 board versus ally16/2/2. +Original Emperor Axlotl transfers after cleanup, allied Production stays with +Atreides, held Arrakeen stays Ecaz and city2 pays12→14. Actual controls, +refreshed force/Terror inspectors and no root390px overflow are observed. + +Only dedicated QA rooms changed; conserved positions are labelled. Owned +throwaway scripts were removed; source reports/private receipts remain outside +Git. Shared-Duke training/exceptional custody, enhanced Terror relocation/ +HMS/Grumman, E3 pairs/mixed families/Advanced Harkonnen/HW/previews, +public/save conversion and full/deployed acceptance remain guarded. +[Canonical source and exact evidence](NEXUS_CARD_RULES.md#standalone-e3-nexus-skills-and-original-components--5-october-2026). + ## 4 October 2026 — classic Nexus with Tech and Advanced Strongholds Two bounded owners supplied original shipment/revival/purchase and coupled diff --git a/docs/LEADER_SKILLS_RULES.md b/docs/LEADER_SKILLS_RULES.md index 383eded5..b2a9258d 100644 --- a/docs/LEADER_SKILLS_RULES.md +++ b/docs/LEADER_SKILLS_RULES.md @@ -202,6 +202,16 @@ This is an explicit conservative implementation interpretation, not a quoted FAQ answer. Legacy battles, other optional modules and modified-skill interactions retain their existing gates. +The 5 October standalone Ecaz Nexus/Skills coalition preserves that own-force +contract: an uncanceled Ecaz lead's committed own dial is the fixed Ecaz +contribution, while its ally lead's own dial is the variable remainder. +Do not subtract the total combined dial from either selected faction's own +force pool. The other allied army cannot use this leader's retreat. +Cancellation uses the selected lead's ordinary own dial. A meaningful original +Nexus-alliance regression now retreats Ecaz's2 undialed counters, rejects3, +and sends only its3 committed counters plus the ally's4 counters to the Tanks. +This is explicit owner-pool composition, not a new combined-skill FAQ. + ## 20 September Smuggler follow-up A bounded renewed check of the GF9 CHOAM & Richese rules/Q&A, the diff --git a/docs/LEADER_SKILLS_RUNTIME.md b/docs/LEADER_SKILLS_RUNTIME.md index ae6737cf..45ac27b0 100644 --- a/docs/LEADER_SKILLS_RUNTIME.md +++ b/docs/LEADER_SKILLS_RUNTIME.md @@ -29,6 +29,37 @@ Advanced native/combined-module skills remain guarded. See [native preview scope](MENTAT_QUESTION.md#native-skill-integration--2-october-2026). +## Standalone E3 Nexus and original components — 5 October 2026 + +Fresh standalone Ecaz OR Moritani plus classics uses ecaz33/all14/all12 +through the original explicit skill/Nexus entry, Basic/Advanced2..6, with +optional original Tech3+ and/or Advanced Strongholds2+; both3+ Advanced. +Native hands/offers/IDs, skill-first setup and actual closing deals remain. +Ecaz keeps five assignment discs and the separate living/unclaimed Duke's +existing quiet Cunning window; normal discipline never assigns that Duke. +Moritani preserves Advanced assassination/normal-Traitor forfeiture, original +post-loss printed bounty and one real private Mentat replacement. +Supply-only Cunning remains separate from ordinary later relocation. + +Mandatory Advanced Ecaz Occupy composes the selected faction's battle skill +without transferring the ally's training. Owner-labelled Suk preserves the +ally's variable losses and rescues Ecaz's own fixed commitment; Diplomat uses +the selected faction's own undialed pool. Both initially failed meaningful +coalition regressions and now pass, including trained rescue/retreat JSON choices. + +Fifty-four original setups/26 prerequisite rejects, new24/affected224 cases, +types/lint/build and all54 genuine games pass:24,845 accepted/no rejection, +648 JSON continuations/28 closing choices/five coalition leads. Three original +captures finish918/752/21 more actions at their original scenario seeds, without +rejected or skipped battles; resume does not reconstruct the pre-capture stream. +Three original-setup human390px rooms exercise living-Duke normal discipline, +unused-trainer assassination/skill return/private replacement/supply Cunning, +and independent fixed-Ecaz rescue before original winner Tech and Collection. +Advanced Harkonnen, paired/mixed E3/HW/ +independent cards/previews, shared/exceptional Duke custody, enhanced Terror +relocation/HMS/Grumman and pending rulings remain guarded. +See [canonical standalone scope](NEXUS_CARD_RULES.md#standalone-e3-nexus-skills-and-original-components--5-october-2026). + ## Paired Nexus with original Tech/Strongholds — 5 October 2026 Fresh original paired Ixians+Tleilaxu OR CHOAM+Richese skill/Nexus entries diff --git a/docs/NEXUS_CARD_RULES.md b/docs/NEXUS_CARD_RULES.md index 0d7fd38e..47991254 100644 --- a/docs/NEXUS_CARD_RULES.md +++ b/docs/NEXUS_CARD_RULES.md @@ -2,6 +2,81 @@ 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. +## Standalone E3 Nexus, Skills and original components — 5 October 2026 + +The original fresh `leader-skills --nexus-cards` entry admits **Ecaz OR +Moritani**, classic opponents and exact ecaz33/all14 Skills/all12 Nexus, +Basic/Advanced2..6. Original unused Tech requires3+; Strongholds requires +Advanced; both3+ Advanced. This is a separate standalone profile, not an +Ecaz+Moritani pair, mixed family, Homeworld, independent-card/preview, +public start or played-game conversion. + +Native starting hands/offers/actor IDs, five ordinary training discs, +skill-before-Traitor order and genuine closing Nexus draws remain. +Ecaz's original living/unclaimed Duke takeover uses the existing quiet Battle +window, native physical leader and normal discipline; the separate Duke cannot +receive an assignment. Used/released/turn-end custody and original aftermath +remain. Dead/captured/Ghola recovery and shared-Duke training stay pending. +Advanced Harkonnen remains excluded. + +Moritani preserves original Advanced skill-first assassination, normal-Traitor +forfeiture for the rest of the game, original post-loss printed bounty and one +actual private Mentat replacement. Basic does not gain that Advanced power. +Cunning remains an earned Mentat supply placement into a static Arrakis +territory/stack, not enhanced relocation/HMS/Grumman; the earlier material +question remains unanswered. Native cancellation and later ordinary relocation +retain their original timing and physical custody. + +Ordinary skill support/rescue, bank-held subsidy/defenses/income, card cleanup +and mandatory original-winner Tech reuse existing handlers. Mandatory Advanced +Ecaz Occupy now uses the chosen faction's actual skill, leaders/cards/payment +and separately labelled armies. Ecaz-led Suk rescues its own fixed casualties +after the ally's variable losses; Diplomat derives the chosen faction's own +undialed pool, not the combined dial. This owner-pool composition is an explicit +implementation inference, not a newly retrieved combined-skill FAQ. Foreign +replacement, modified collection and other pending combinations remain guarded. + +```sh +node --import tsx tools/start-prototype.ts --profile leader-skills \ + --nexus-cards --db /private/local.sqlite --room ROOMCODE --version 9 \ + --out /private/new-standalone-e3-nexus-skills-entry +node --import tsx tools/faction-games.ts --profile e3-nexus-skills \ + --players all --rules both --out /private/new-standalone-e3-nexus-skills-games +``` + +The separate `e3-nexus-skills-tech`, `e3-nexus-skills-stronghold` and +`e3-nexus-skills-stronghold-tech` samples preserve those selected original +components. Fifty-four original setup programs/four legal policies and26 +retained prerequisite rejects pass. New24/affected224 cases, types/lint/build +pass. Fifty-four genuine2..6 games complete24,845 accepted actions/no rejection, +648 JSON continuations,28 closing choices and five original coalition leads on +unchanged tree `ceebd866c4de7dfd122c0ddb9754a730b7bbdd93340afddffbd26ec157561816`. +Three original mandatory Occupy captures also finish:918/752/21 additional +accepted actions without rejection, retaining scenario seeds20263035/20263132/ +20263228. Resume restarts the stream at that original seed; it does not recreate +the pre-capture random stream. No battle or saved position was replaced. + +Original backed-up CLI **YZLNTNEZ/R2S5UX5U v10** retains offers/hands/IDs. +Human390px **YZLNTNEZ v23/Collection** spends living-Duke Cunning, wins7–5 +with Duke6/normal Warmaster/Worthless/held Carthag Shield against actual poison, +releases Duke before cleanup, receives original Emperor Production and city2 +(wallet12→14). **R2S5UX5U v27/Mentat** keeps original normal Guild Suk rescue +(15 reserves/2 Tanks/3 Tuek forces), then reveals unused Master Bewt3 after +losing: one death/skill return and bounty11→14, original Guild winner cleanup/ +Production reward, one actual private Mentat replacement and supply Robbery +Cunning at Red Chasm. There was no eligible native Karama counter in that human +placement; actual cancellation/stack paths are separate source programs. + +Original three-seat **L7C8HTQT v8/setup** continues a real alternate Nexus +alliance before conserved staging. Human390px **v23/Collection** chooses Ecaz +lead, trained Sanya4/dial5 (fixed3/variable2)/support2 with bank2/own0, wins9–6, +settles ally2 losses first and rescues only Ecaz3:17 reserves/0 Tanks/3 board; +Atreides16 reserves/2 Tanks/2 board. Original Emperor Axlotl transfers after +card cleanup; allied Production remains Atreides. Held Arrakeen remains Ecaz; +separate city2 pays12→14. Actual controls, inspectors and refresh retain these +outcomes without root390px overflow. Controlled positions are labelled; +no natural Cunning/rescue, full-module, public or deployed acceptance is claimed. + ## Paired Nexus, Skills and original Tech/Strongholds — 5 October 2026 The original fresh `leader-skills --nexus-cards` entry now preserves selected diff --git a/docs/NEXUS_ECAZ_RULES.md b/docs/NEXUS_ECAZ_RULES.md index 10a8bbbb..04a05173 100644 --- a/docs/NEXUS_ECAZ_RULES.md +++ b/docs/NEXUS_ECAZ_RULES.md @@ -109,9 +109,22 @@ are enabled. The face does not print a Battle-only timing restriction; the quiet phase-six window is a product implementation boundary, **not** a GF9 ruling. Advanced Harkonnen tables are also excluded: the existing shared leader battle path cannot select Duke there, so the card must not be spent -for an unusable disc. Other expansion factions, Homeworlds, Discoveries, -Leader Skills and combined optional modules remain gated. This is not full -Ecaz, Moritani or Nexus module acceptance. +for an unusable disc. At that initial checkpoint other expansion factions, +Homeworlds, Discoveries, Leader Skills and combined optional modules remained +gated. This was not full Ecaz, Moritani or Nexus module acceptance. + +### Standalone skill/Nexus follow-up — 5 October 2026 + +The fresh standalone Ecaz/classic ecaz33/all14/all12 entry now composes the +same quiet living/unclaimed Duke Cunning with ordinary native disciplines +and selected original Tech/Advanced Strongholds. Moritani need not be seated. +Five ordinary training discs remain; the shared Duke receives no assignment. +Human YZLNTNEZ uses Duke6 with face-up normal Warmaster/Worthless and a held +Shield against actual poison, wins7–5, releases Duke before cleanup and keeps +the original winner reward/city income. Advanced Harkonnen, dead/captured/ +Ghola recovery, shared-Duke training and unrelated overlays remain guarded. +See [the exact standalone profile and evidence](NEXUS_CARD_RULES.md#standalone-e3-nexus-skills-and-original-components--5-october-2026). + [The focused Duke tests](../tests/nexus-ecaz-duke-engine.test.ts) exercise genuine Ecaz/Moritani setup, physical draw/spend, Basic and Advanced transfer, diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 662bf3ea..199705fc 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -155,6 +155,7 @@ See [the cutover and Advanced sequence](MORITANI_ASSASSINATE_LEADERS.md#authorit | Topic | Decision to preserve | Detailed authority and evidence | | --- | --- | --- | +| Standalone E3 Nexus / Skills / original components | Fresh Ecaz OR Moritani/classic ecaz33/all14/all12, Basic/Advanced2..6, original Tech3+/Advanced Strongholds2+. Original Duke/assassination/Terror, chosen-plan Advanced Occupy and separate physical armies remain. Ecaz-led Suk rescues only its own fixed casualties; Diplomat uses the selected faction's own undialed commitment. Owner-pool skill composition is an explicit implementation inference, not a combined-skill FAQ. E3 pairs/Harkonnen Advanced/HW/previews/shared-Duke training/enhanced relocation and pending rulings remain guarded. | [Source and verified scope](NEXUS_CARD_RULES.md#standalone-e3-nexus-skills-and-original-components--5-october-2026), [coalition contract](ECAZ_OCCUPY_RULES.md#standalone-nexus--skills-coalition--5-october-2026) | | Harkonnen special-Karama forced return | Return automatically only when the combined hand equals the count owed. Keep actual drawn-card observations owner-private after settlement; legacy forced saves describe held-card history without inventing draw provenance. Existing provisional auction-payment recovery and mode gates remain. | [Contract and limits](HARKONNEN_EXCHANGE.md) | | Basic Moritani / Leader Skills | Ordinary Terror death returns the assigned skill once; own revival offers the existing private optional replacement. Sabotage does not take skills, alliances do not transfer assignments, and mandatory Planetologist/Diplomat disposal excludes those cards from ally retention. This is composition of existing contracts, not a new ruling or resolution of unfinished skill effects. Advanced and other roster/module combinations remain gated. | [Integration contract](MORITANI_LEADER_SKILLS.md) | | Ecaz Loyalty | Before initial Traitor dealing, randomly set aside one native Ecaz card face up permanently. No Karama effect; no change to its leader disc. Resolving after existing pre-Traitor setup is an explicit sequencing composition; native ordinary-disc Skills now precede this draw through public assignment. Older dealt saves are not retrofitted; wider Skills/assassination combinations remain gated. | [Official sources and runtime](ECAZ_LOYALTY.md), [native skill composition](LEADER_SKILLS_RUNTIME.md#native-ecaz-ordinary-disc-composition--4-october-2026) | diff --git a/docs/SUK_GRADUATE_RULES.md b/docs/SUK_GRADUATE_RULES.md index 10fae2cc..78feb688 100644 --- a/docs/SUK_GRADUATE_RULES.md +++ b/docs/SUK_GRADUATE_RULES.md @@ -51,10 +51,30 @@ and any eligible sector choice remain visible to the player. Suk settles before winner card choices and faction aftermath, including capture and Face Dance. Mandatory loser-card cleanup uses its existing continuation. -The exact ordering against future combined optional card interruptions still -needs integrated acceptance. Homeworld custody, Nexus and expansion-faction -combinations are explicitly rejected before resources or traitor votes commit; -the genuine Leader Skills initializer currently admits base factions only. +The exact ordering against additional optional interruptions still needs +integrated acceptance. Later bounded native/module profiles and their exact +evidence are recorded in [the runtime scope](LEADER_SKILLS_RUNTIME.md); they +do not certify full Skills or remove the pending rulings. + +## Ecaz-led Occupy own-force rescue — 5 October 2026 + +Standalone Ecaz Nexus/Skills composes mandatory Advanced Occupy with separate +physical armies. When Ecaz leads and has the eligible Suk band, retain its +original fixed Ecaz commitment for rescue instead of feeding the ally's +variable commitment into Ecaz's force pool. Settle the ally's losses first, +then normal or trained Suk on Ecaz's actual counters, then original winner +cards/Tech and subsequent Collection. A receipt retains the pending allied +commitment until that separate settlement; JSON choices preserve the own pool. +An ally lead uses its own ordinary variable casualties. No training is lent +between allies. This owner-labelled composition is an explicit implementation +inference from the card and Occupy selected-plan rule, not a new GF9 FAQ. + +Four selected module bands cover normal Ecaz3 losses→2 Tanks/1 reserve; +trained rescue keeps1/returns2 and leaves the ally's2 losses unchanged. +Human L7C8HTQT confirms17 Ecaz reserves/0 Tanks/3 board, ally16 reserves/ +2 Tanks/2 board and held Arrakeen bank2/own0 before cleanup and city income. +See [standalone source and evidence](NEXUS_CARD_RULES.md#standalone-e3-nexus-skills-and-original-components--5-october-2026). + ## Atreides loss-count question diff --git a/game/bureaucrat-payment.ts b/game/bureaucrat-payment.ts index e4f7f93c..5cfc579d 100644 --- a/game/bureaucrat-payment.ts +++ b/game/bureaucrat-payment.ts @@ -1,4 +1,4 @@ -import { classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, noOtherLeaderSkillModules, strongholdLeaderSkillsProfile, type LeaderSkillProfile } from './leader-skill-profile'; +import { classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, noOtherLeaderSkillModules, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile, type LeaderSkillProfile } from './leader-skill-profile'; export type BureaucratPaymentKind = 'auction' | 'shipment' | 'bribe' | 'revival'; export type BureaucratPaymentSource = { event: string; @@ -38,7 +38,7 @@ export function bureaucratPaymentModeSupported(game: LeaderSkillProfile): boolea return (noOtherLeaderSkillModules(game) && (!game.expansions.length || (game.expansions.length === 1 && game.expansions[0] === 'choam') || - nativeExpansionLeaderSkillsProfile(game))) || classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game) || strongholdLeaderSkillsProfile(game) || classicNexusLeaderSkillsProfile(game); + nativeExpansionLeaderSkillsProfile(game))) || classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game) || strongholdLeaderSkillsProfile(game) || classicNexusLeaderSkillsProfile(game) || standaloneE3NexusLeaderSkillsProfile(game); } export function bureaucratUsed( used: readonly BureaucratPaymentUse[], diff --git a/game/engine.ts b/game/engine.ts index 80817371..5d8c50e8 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1,6 +1,6 @@ import { classicNexusModulesProfile, pairedNexusModulesProfile } from './nexus-module-profile'; import { nativeFactionTechProfile } from './faction-module-profile'; -import { advancedMoritaniLeaderSkillsProfile, advancedNativeLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, pairedNexusLeaderSkillsProfile, strongholdLeaderSkillsProfile } from './leader-skill-profile'; +import { advancedMoritaniLeaderSkillsProfile, advancedNativeLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, pairedNexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile } from './leader-skill-profile'; import { bribeTimingBlock, maximumBribe, type BribeOptions } from './bribe-options'; import { quoteSpicePlacement, stormExposesTerritory, stormSectorAfter, wormConsumesForces } from './disaster-rules'; import { isStormCardDistance, type StormCardComponent } from './storm-cards'; @@ -7539,8 +7539,9 @@ function e3StrongholdFactionProfile(g: Game): boolean { function moritaniAssassinateModeSupported(g: Game) { return g.advanced && g.expansions.length === 1 && g.expansions[0] === 'ecaz' && g.players.some(p => p.faction === 'moritani') && - g.players.every(p => ['moritani','atreides','beneGesserit','guild','emperor','fremen'].includes(p.faction)) && - !g.nexusCards && (!g.leaderSkills || advancedMoritaniLeaderSkillsProfile(g)) && + g.players.every(p => p.faction === 'moritani' || (p.faction !== 'harkonnen' && CLASSIC_FACTIONS[p.faction] === true)) && + (!g.nexusCards || (!!g.leaderSkills && standaloneE3NexusLeaderSkillsProfile(g))) && + (!g.leaderSkills || advancedMoritaniLeaderSkillsProfile(g)) && (!g.strongholdCards || e3StrongholdFactionProfile(g) || (!!g.leaderSkills && strongholdLeaderSkillsProfile(g))) && !g.homeworlds && !g.discoveryEnabled && !g.discoveries && @@ -9267,6 +9268,8 @@ export function initializeFactionExpansionsGameForAudit(state: Game): Game { return initialized; } function ecazOccupyCompositionSupported(g: Game): boolean { + if (g.leaderSkills && standaloneE3NexusLeaderSkillsProfile(g) && + g.players.some(p => p.faction === 'ecaz')) return true; return typeof g.advanced === 'boolean' && Array.isArray(g.expansions) && g.expansions.includes('ecaz') && g.expansions.every(id => ['ecaz', 'ix', 'choam'].includes(id)) && @@ -9403,7 +9406,7 @@ export function initializeLeaderSkillsGameForAudit(state: Game): Game { requireRule(!state.leaderSkills, 'Leader Skills cannot redeal existing skill cards.'); const g = structuredClone(state); g.leaderSkills = createLeaderSkills(random); - const initialized = initializeSetupGameForAudit(g, false, classicNexusLeaderSkillsProfile(g) || pairedNexusLeaderSkillsProfile(g), false, false, true, + const initialized = initializeSetupGameForAudit(g, false, classicNexusLeaderSkillsProfile(g) || pairedNexusLeaderSkillsProfile(g) || standaloneE3NexusLeaderSkillsProfile(g), false, false, true, g.expansions.length === 1 && g.expansions[0] === 'choam', nativeExpansionLeaderSkillsProfile(g)); if (advancedMoritaniLeaderSkillsProfile(initialized)) initializeMoritaniAssassinateState(initialized); return initialized; @@ -9443,7 +9446,7 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f const skillTech = leaderSkills && (classicTechLeaderSkillsProfile(g) || nativeTechLeaderSkillsProfile(g) || skillStronghold); const nativeTech = factions && !leaderSkills && nativeFactionTechProfile(g); const nexusModules = nexus && (classicNexusModulesProfile(g) || pairedNexusModulesProfile(g) || - (leaderSkills && (classicNexusLeaderSkillsProfile(g) || pairedNexusLeaderSkillsProfile(g)))); + (leaderSkills && (classicNexusLeaderSkillsProfile(g) || pairedNexusLeaderSkillsProfile(g) || standaloneE3NexusLeaderSkillsProfile(g)))); requireRule(!g.techTokens || ((skillTech || nativeTech || nexusModules) && JSON.stringify(g.techTokens) === JSON.stringify(createTechTokens())), 'Tech Tokens require a fresh supported three-through-six-seat lobby with unused tokens.'); @@ -20470,6 +20473,13 @@ function diplomatRetreatOffer(g: Game, quote: BattleResolutionQuote) { (!skill.faceUp || skill.captured)) || p.noField?.deployed?.location.territory === b.territory) return null; const opponent = getPlayer(g, quote.winner); const forces = combatForces(g, p, b.territory, opponent); + const occupy = ecazBattleProfile(g); + const combined = occupy?.lead === player && !occupy.canceled; + const dial = combined + ? player === occupy.ecaz ? occupy.fixedEcazDial : plan.dial - occupy.fixedEcazDial + : plan.dial; + const support = combined && player === occupy.ecaz + ? g.advanced ? dial : 0 : plan.support; const strength = battleLeaderStrength(leader, controlledLeaders(g, opponent).find(candidate => candidate.id === b.plans[opponent.id].leader)); const origins = Object.entries(p.forces).filter(([key, count]) => @@ -20486,7 +20496,7 @@ function diplomatRetreatOffer(g: Game, quote: BattleResolutionQuote) { const elite = p.elites?.forces[origin] ?? 0; return { normal: sum.normal + count - elite, elite: sum.elite + elite }; }, { normal: 0, elite: 0 }); - return { location: key, choices: diplomatRetreatChoices(forces, plan.dial, plan.support, strength, available) }; + return { location: key, choices: diplomatRetreatChoices(forces, dial, support, strength, available) }; }) .filter(option => option.choices.length); return destinations.length ? { player, destinations } : null; @@ -20602,6 +20612,8 @@ function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) { }; const winner = quote.winner ? getPlayer(g, quote.winner) : undefined; const casualtyCommitment = quote.casualties ?? undefined; + const fixedSukLoss = winner && quote.sukGraduate + ? quote.fixedLosses?.find(loss => loss.owner === winner.id) : undefined; const sardaukar = currentNexusSardaukar(g); if (sardaukar?.stage === 'active' && casualtyCommitment && winner?.id === sardaukar.receipt.owner) { sardaukar.casualties = {...structuredClone(casualtyCommitment),outcome:'pending'}; @@ -20760,6 +20772,7 @@ function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) { : { faction: winner!.faction, name: 'Battle' }, ); for (const loss of quote.fixedLosses ?? []) { + if (loss === fixedSukLoss) continue; const owner = getPlayer(g, loss.owner); takeBattleLosses(g, owner, b.territory, { normal: loss.normal, elite: loss.elite, paidNormal: 0, paidElite: 0 }); log(g, `${owner.name} sent ${loss.normal + loss.elite} mandatory Occupy forces to the Tanks.`, { faction: 'ecaz', name: 'Occupy casualties' }); @@ -20934,12 +20947,19 @@ function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) { } if (winner && quote.sukGraduate && casualtyCommitment) { requireRule(!g.pendingSukRescue, 'Finish the preceding Suk Graduate rescue.'); + let commitment: SukRescueReceipt['commitment']; + if (fixedSukLoss) { + const forces = combatForces(g, winner, b.territory, losingPlayer!); + const dial = fixedSukLoss.normal + fixedSukLoss.elite, support = g.advanced ? dial : 0; + commitment = { forces, dial, support, options: casualtyOptions(forces, dial, support) }; + } else commitment = structuredClone(casualtyCommitment); g.pendingSukRescue = { event: g.lastBattleContext.event, turn: g.turn, player: winner.id, territory: b.territory, skill: quote.sukGraduate, - commitment: structuredClone(casualtyCommitment), pool: sukForcePool(winner, b.territory), + commitment, pool: sukForcePool(winner, b.territory), cards: [...quote.winnerCards], physical: sukPhysicalSignature(winner), losses: null, signature: '', ...(winner.faction === 'ixians' ? { eliteOrigins: true as const } : {}), + ...(fixedSukLoss ? { occupyCasualties: { ...structuredClone(casualtyCommitment), owner: casualtyCommitment.owner! } } : {}), }; updateSukReceipt(g); } @@ -21060,6 +21080,18 @@ function settleWinnerCasualties( ) { if (g.pendingSukRescue) { const pending = g.pendingSukRescue; + if (pending.occupyCasualties) { + const allied = pending.occupyCasualties; + requireRule(forceOwner === allied.owner && forceOwner !== p.id && + allied.options.some(option => JSON.stringify(option) === JSON.stringify(choice)), + 'Choose the original allied Occupy casualties before rescuing your own forces.'); + takeBattleLosses(g, getPlayer(g, forceOwner), to, choice); + delete pending.occupyCasualties; + updateSukReceipt(g); + observeOccupation(g); + settleWinnerCasualties(g, p, to, cards, pending.commitment.options[0], automatic); + return; + } requireRule(pending.player === p.id && pending.territory === to && JSON.stringify(cards) === JSON.stringify(pending.cards) && !pending.losses && pending.commitment.options.some((o) => JSON.stringify(o) === JSON.stringify(choice)), @@ -21202,11 +21234,13 @@ function sukRescueIntegrity(g: Game) { } else { const lossDecision = homeworldSavedDecisions(g).find((d) => d.kind === 'battleLosses' && d.player === pending.player); const discard = g.pendingTreacheryDiscard?.continuation; + const commitment = pending.occupyCasualties ?? pending.commitment; requireRule(!decisions.length && (lossDecision ? lossDecision.kind === 'battleLosses' && - lossDecision.territory === pending.territory && JSON.stringify(lossDecision.options) === JSON.stringify(pending.commitment.options) && + lossDecision.territory === pending.territory && JSON.stringify(lossDecision.options) === JSON.stringify(commitment.options) && + (!pending.occupyCasualties || lossDecision.forceOwner === pending.occupyCasualties.owner) && JSON.stringify(lossDecision.cards) === JSON.stringify(pending.cards) : discard?.kind === 'battleResolved' && discard.event === pending.event && - JSON.stringify(discard.casualties) === JSON.stringify(pending.commitment)), + JSON.stringify(discard.casualties) === JSON.stringify(commitment)), 'The Suk Graduate rescue lost its casualty continuation.'); } } diff --git a/game/leader-skill-profile.ts b/game/leader-skill-profile.ts index e790d5a1..9a31dccc 100644 --- a/game/leader-skill-profile.ts +++ b/game/leader-skill-profile.ts @@ -33,14 +33,15 @@ export function noOtherLeaderSkillModules(game: LeaderSkillProfile): boolean { !game.discoveryEnabled && !game.strongholdCards && !game.techTokens && !game.ecazTreachery; } -function nexusLeaderSkillModulesSupported(game: LeaderSkillProfile): boolean { +function nexusLeaderSkillModulesSupported(game: LeaderSkillProfile, assassination = false): boolean { return !!game.nexusCards && typeof game.advanced === 'boolean' && !!game.players && game.players.length >= 2 && game.players.length <= 6 && !game.homeworlds && !game.discoveries && !game.discoveryEnabled && !game.discoveryStash && !game.greatMaker && (!game.techTokens || game.players.length >= 3) && (!game.strongholdCards || game.advanced === true) && - !game.ecazTreachery && !game.semutaPreview && !game.moritaniAssassinatePreview && + !game.ecazTreachery && !game.semutaPreview && + (!game.moritaniAssassinatePreview || assassination) && !game.advancedPreview && !game.kullPreview && !game.nexusKullPreview && !game.guildBetrayalPreview && !game.richeseBetrayalPreview && !game.nexusIxianReplacementPreview && !game.nexusIxianBetrayalPreview && @@ -83,6 +84,30 @@ export function pairedNexusLeaderSkillsProfile(game: LeaderSkillProfile): boolea return hasPrimary && hasSecondary; } +/** One original E3 native with classics; 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; + const players = game.players!; + let native: 'ecaz' | 'moritani' | null = null, harkonnen = false; + for (let i = 0; i < players.length; i++) { + for (let j = 0; j < i; j++) if (players[j].faction === players[i].faction) return false; + const player = players[i]; + if (player.faction === 'ecaz' || player.faction === 'moritani') { + if (native !== null) return false; + native = player.faction; + } else { + let classic = false; + for (const faction of FACTIONS) + if (faction.id === player.faction) { classic = faction.expansion === 'base'; break; } + if (!classic) return false; + if (player.faction === 'harkonnen') harkonnen = true; + } + } + return native !== null && !(game.advanced && harkonnen) && + (native === 'moritani' || !game.moritaniAssassinatePreview); +} + /** E1/E2 native skills may add Tech and Advanced Strongholds; original roster/decks remain. */ function nativeLeaderSkillModulesSupported(game: LeaderSkillProfile): boolean { if (noOtherLeaderSkillModules(game) || pairedNexusLeaderSkillsProfile(game)) return true; @@ -106,7 +131,7 @@ function nativeLeaderSkillModulesSupported(game: LeaderSkillProfile): boolean { /** Standalone E3 native predicates retain their own roster, Duke and assassination limits. */ function e3LeaderSkillModulesSupported(game: LeaderSkillProfile): boolean { - if (noOtherLeaderSkillModules(game)) return true; + if (noOtherLeaderSkillModules(game) || standaloneE3NexusLeaderSkillsProfile(game)) return true; const players = game.players; if ((!game.techTokens && !game.strongholdCards) || !players || players.length < (game.techTokens ? 3 : 2) || players.length > 6 || @@ -331,5 +356,5 @@ export function ordinaryLeaderSkillModeSupported(game: LeaderSkillProfile): bool (!game.expansions.length || nativeExpansionLeaderSkillsProfile(game))) || classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game) || strongholdLeaderSkillsProfile(game) || classicNexusLeaderSkillsProfile(game) || - pairedNexusLeaderSkillsProfile(game); + pairedNexusLeaderSkillsProfile(game) || standaloneE3NexusLeaderSkillsProfile(game); } diff --git a/game/nexus-ecaz-duke.ts b/game/nexus-ecaz-duke.ts index 8091e9fb..b05f62d6 100644 --- a/game/nexus-ecaz-duke.ts +++ b/game/nexus-ecaz-duke.ts @@ -3,6 +3,7 @@ import type { Game } from './engine'; import { acquireDuke, DUKE_VIDAL_ID, type DukeSource } from './duke-vidal'; import { nexusCardMode } from './nexus-cards'; import { nexusCleanPlayBlocked } from './nexus-play-boundary'; +import { standaloneE3NexusLeaderSkillsProfile } from './leader-skill-profile'; const SUPPORTED_FACTIONS: Partial> = { ecaz: true, moritani: true, atreides: true, harkonnen: true, @@ -64,7 +65,7 @@ export function quoteNexusEcazDuke(g: Game, owner: string, automaticPending = fa if (g.advanced && g.players.some(player => player.faction === 'harkonnen')) blocked = 'Duke Vidal battle use is unavailable with Advanced Harkonnen.'; else if (!nexusEcazDukeModeSupported(g) || nexusCardMode('ecaz', holder.faction, roster) !== 'cunning') - blocked = 'Ecaz Nexus Cunning currently requires paired Ecaz and Moritani with Nexus alone.'; + blocked = 'Ecaz Nexus Cunning requires the paired Nexus-only roster or fresh standalone Ecaz Skills/Nexus profile.'; else if (holder.ally) blocked = 'Use Ecaz Nexus Cunning while unallied.'; else if (g.status !== 'playing' || g.phase !== 6) blocked = 'Use Ecaz Nexus Cunning at the Battle phase boundary.'; @@ -86,6 +87,9 @@ export function quoteNexusEcazDuke(g: Game, owner: string, automaticPending = fa } export function nexusEcazDukeModeSupported(g: Game): boolean { + if (g.leaderSkills && standaloneE3NexusLeaderSkillsProfile(g) && + g.players.some(player => player.faction === 'ecaz') && !g.sandtrout) + return !!g.nexusCards?.cards && g.players.length >= 3; return !!g.nexusCards?.cards && g.players.length >= 3 && g.players.length <= 6 && g.players.some(player => player.faction === 'ecaz') && g.players.some(player => player.faction === 'moritani') && diff --git a/game/reference.ts b/game/reference.ts index dade52fd..021b040b 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -4369,6 +4369,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'The same fresh local Nexus entry also composes Tech and/or Advanced Strongholds with paired Ixians + Tleilaxu OR CHOAM + Richese and classic opponents, one original family deck and no Skills. Tech needs three through six seats; Strongholds needs two through six Advanced seats. Real closing draws, native typed forces, physical Face Dancers and No-Fields, original payments/rewards and held effects remain. Mixed families, E3, Homeworlds, unrelated previews, public starts and unresolved rulings are not opened.', 'The separate fresh local leader-skills --nexus-cards entry combines classic base33/all14 Skills/all12 Nexus in Basic or Advanced, two through six seats. Selected original Tech Tokens require three or more seats; Stronghold Cards require Advanced. Original starting hands/offers, closing draws, first-Storm Tech and end-Mentat held custody remain. Extra/ordinary free-three returns earn original phase-end Axlotl without changing skill custody. Emperor Cunning composes skill posture, support and Suk rescue; Arrakeen reduces payer cost, not committed support or rescue eligibility. Winner cleanup precedes mandatory Tech reward. Other overlays and public starts remain guarded.', 'The same fresh skill/Nexus entry supports both Ixians + Tleilaxu OR both CHOAM + Richese with classics and one original family deck/all14/all12, Basic/Advanced two through six seats. Selected original Tech requires three or more; Strongholds requires Advanced. Full Suboid Cunning composes typed skill rescue before equal actual Cyborg Tank substitution. Original held subsidy changes debit, not rescue support. Card cleanup and mandatory original-winner Tech precede Face Dance; a matching trainer dies and returns its skill once without another bounty, while held cards and tokens remain with the original winner. Partial Tleilaxu refresh preserves Mentat rights. Original signed Richese pair and CHOAM fuel/industry stay distinct. Mixed families, E3, companions/mixed plans, native free-return accounting and pending rulings stay guarded.', + 'A separate standalone Ecaz OR Moritani skill/Nexus entry uses exact ecaz33/all14/all12 with classics, Basic/Advanced two through six seats; original Tech requires3+, Strongholds requires Advanced. Actual closing draws, five ordinary training discs, quiet living/unclaimed Duke Cunning and original Advanced assassination/normal-Traitor forfeiture remain. Native support/rescue, held effects, original card cleanup and mandatory winner Tech use existing handlers. Advanced Harkonnen, E3 pairs/mixed families/HW/previews, Duke training/exceptional custody, enhanced Terror relocation/HMS/Grumman and other pending rulings stay guarded.', 'Atreides Cunning adds a second, different opposing plan element after the first native answer. Both remain binding. Use it in your own battle before sealing your plan; Karama may cancel the extra attempt while preserving the first disclosure.', 'With Atreides absent, Secret Ally inspects one opposing element in your battle. A sealed plan supplies only that selected answer automatically. Otherwise the opponent chooses an answer that permits a legal plan. Other players receive no private answer.', 'The No-Field restriction names Atreides, so Cunning cannot inspect that dial. A non-Atreides Secret Ally holder may request it without learning the token denomination or other plan elements.', @@ -4419,7 +4420,7 @@ export const RULE_TOPICS: RuleTopic[] = [ { area: 'Player controls', status: 'Partial', detail: 'Private lifecycle, inspections, exchanges, Tleilaxu refresh, Ixian activation, advisor selection, temporary Sardaukar and CHOAM effects, Moritani Betrayal, Ecaz Betrayal and owner-only Secret Ally inquiry, native Duke Cunning, Richese shipment and bounded auction Betrayal, Emperor purchase and Fremen Cunning controls are connected. Remaining effect controls are unfinished.', evidence: ['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/nexus-ecaz-duke.tsx', 'components/nexus-richese-betrayal.tsx', 'components/game-table.tsx'] }, { area: 'AI', status: 'Partial', detail: 'All four profiles choose legal actions through private offers, including Moritani/Ecaz Betrayal, Ecaz Secret Ally inquiry and native Duke Cunning, Fremen Cunning, Emperor purchase and bounded Richese auction Betrayal. Complete module strategy and calibration remain unfinished.', evidence: ['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/nexus-ecaz-duke-options.ts', 'game/nexus-richese-options.ts', 'game/bot-nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts'] }, { area: 'Documentation', status: 'Partial', detail: 'Common rules, twelve printed faces and bounded runtime contracts retain explicit unresolved Ecaz Duke capture/Tanks/Ghola and inquiry audience/scope, Fremen nested Cunning, Great Maker, combined modules, fresh advisors, storm and borrowed Voice. Richese auction Betrayal has a separate cost/prepayment/privacy contract and excluded-producer boundaries. Guild Betrayal separately records full funded payment, own pre-funding, donor escrow once, the printed full-Junction override, four native adapters and neutral privacy; ordinary occupation/rounding and general response-priority questions remain unresolved.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_FREMEN_RULES.md', 'docs/NEXUS_ECAZ_RULES.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_RICHESE_RULES.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md', 'docs/NEXUS_GUILD_RULES.md'] }, - { area: 'Verification', status: 'Partial', detail: 'Physical custody, commitments, controls, legal bots, Basic/Advanced and in-memory SQLite recovery regressions include native Ecaz Duke override and private Secret Ally inquiry. A read-only local browser fixture showed the owner-only Duke choice and no rival offer; no room was written. Bounded Richese auction Betrayal additionally has local CLI/runtime, authenticated SQLite and refreshed phone Use/Pass evidence in owned disposable rooms. New Guild Betrayal verification is separately Planned pending frozen direct evidence; these earlier results do not certify it. Fresh classic and paired E1/E2 Tech/Stronghold compositions have bounded runtime cases, genuine games and original CLI/phone evidence. Classic all14 Skills additionally has actual free-return/purchase/exchange and Cunning/Suk lifecycle programs, original phone controls and genuine2..6-seat games. Full module games, exceptional Duke custody, combined interactions and deployed acceptance remain separate.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-controls.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts', 'tests/classic-nexus-skills-payments-runtime.test.ts', 'tests/classic-nexus-skills-battles-runtime.test.ts', 'tests/nexus-skills-modules-payments-runtime.test.ts', 'tests/nexus-skills-modules-battles-runtime.test.ts', 'tests/paired-ix-nexus-skills-modules-runtime.test.ts', 'tests/paired-choam-nexus-skills-modules-runtime.test.ts'] }, + { area: 'Verification', status: 'Partial', detail: 'Physical custody, commitments, controls, legal bots, Basic/Advanced and in-memory SQLite recovery regressions include native Ecaz Duke override and private Secret Ally inquiry. A read-only local browser fixture showed the owner-only Duke choice and no rival offer; no room was written. Bounded Richese auction Betrayal additionally has local CLI/runtime, authenticated SQLite and refreshed phone Use/Pass evidence in owned disposable rooms. New Guild Betrayal verification is separately Planned pending frozen direct evidence; these earlier results do not certify it. Fresh classic and paired E1/E2 Tech/Stronghold compositions have bounded runtime cases, genuine games and original CLI/phone evidence. Classic all14 Skills additionally has actual free-return/purchase/exchange and Cunning/Suk lifecycle programs, original phone controls and genuine2..6-seat games. Full module games, exceptional Duke custody, combined interactions and deployed acceptance remain separate.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-controls.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts', 'tests/classic-nexus-skills-payments-runtime.test.ts', 'tests/classic-nexus-skills-battles-runtime.test.ts', 'tests/nexus-skills-modules-payments-runtime.test.ts', 'tests/nexus-skills-modules-battles-runtime.test.ts', 'tests/paired-ix-nexus-skills-modules-runtime.test.ts', 'tests/paired-choam-nexus-skills-modules-runtime.test.ts', 'tests/ecaz-nexus-skills-modules-runtime.test.ts', 'tests/moritani-nexus-skills-modules-runtime.test.ts'] }, ], }, { @@ -4608,6 +4609,8 @@ export const RULE_TOPICS: RuleTopic[] = [ '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. Saved hands/offers remain authoritative; actual closing draws remain required. 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, native factions with these combined components, Banker/Mentat previews, borrowed Smuggler arithmetic and unresolved interactions remain separate.', '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.', 'With this optional module, starting Treachery Cards are dealt after prediction and before skill assignment. Every faction privately receives two skills, keeps one, returns the other to the shuffled deck, and assigns its chosen card to an eligible leader. Skill and leader become public before traitor selection.', 'Before Battle Plans, leave the skilled leader face up to keep its normal effect, or move it behind the shield for possible use. A face-up leader cannot be selected for the plan. Concealing it while choosing another leader gives neither effect. A leader that must be used because no alternative remains moves behind the shield automatically.', 'Warmaster, Master of Assassins, Swordmaster of Ginaz, Killer Medic and Prana-Bindu Adept are connected. They give another leader +1 for the matching physical card role, or the surviving assigned leader +3. These bonuses do not stack to +4. They change battle strength, not the printed disc value or leader bounty.', diff --git a/game/suk-graduate.ts b/game/suk-graduate.ts index a166fa5e..8ff43c62 100644 --- a/game/suk-graduate.ts +++ b/game/suk-graduate.ts @@ -18,6 +18,8 @@ export type SukRescueReceipt = { territory: string; skill: SukGraduateSkill; commitment: { forces: CombatForces; dial: number; support: number; options: Casualties[] }; + /** Ecaz-led Occupy settles the ally's variable losses before this own-force rescue. */ + occupyCasualties?: { owner: string; forces: CombatForces; dial: number; support: number; options: Casualties[] }; pool: SukForceGroup[]; cards: string[]; physical: string; diff --git a/tests/ecaz-nexus-skills-modules-runtime.test.ts b/tests/ecaz-nexus-skills-modules-runtime.test.ts new file mode 100644 index 00000000..521f23d0 --- /dev/null +++ b/tests/ecaz-nexus-skills-modules-runtime.test.ts @@ -0,0 +1,381 @@ +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 { DUKE_VIDAL_ID } from '../game/duke-vidal'; +import { quoteNexusEcazDuke, createNexusEcazDukeReceipt } from '../game/nexus-ecaz-duke'; +import { ownedTech } from '../game/tech-tokens'; +import { + advanceEcazNexusSkillsModules as advance, assertEcazNexusSkillsModulesCustody as custody, + completeEcazNexusSkillsModulesSetup as complete, createEcazNexusSkillsModulesFixture as fixture, + ecazNexusSkillsModulesAction as act, ecazNexusSkillsModulesCurrentControl as controls, + ecazNexusSkillsModulesPlayer as player, ecazNexusSkillsModulesSourceFacts as facts, + ecazNexusSkillsModulesTrainer as trainer, expireEcazNexusSkillsModulesDuke as expire, + initializeEcazNexusSkillsModules as initialize, reloadEcazNexusSkillsModules as reload, + revealEcazNexusSkillsModulesBattle as reveal, settleEcazNexusSkillsModulesBattle as settle, + stepEcazNexusSkillsModules as step, type EcazNexusSkillsModulesOptions, +} from './fixture-ecaz-nexus-skills-modules'; + +function reject(game: Game, actor: string, action: Action): void { + const before = structuredClone(game); + assert.throws(() => applyAction(game, actor, action)); + assert.deepEqual(game, before, 'Rejected actions must preserve the actual original source.'); +} +function policy(game: Game, actor: string, difficulty: typeof DIFFICULTIES[number]): Action { + const view = viewGame(reload(game), actor); + view.players.find(p => p.id === actor)!.bot = difficulty; + const action = botActions(view)[0]; assert.ok(action); + return action; +} +const profiles: EcazNexusSkillsModulesOptions[] = [ + { rules: 'basic', tech: false, stronghold: false }, + { rules: 'advanced', tech: false, stronghold: false }, + { rules: 'basic', tech: true, stronghold: false }, + { rules: 'advanced', tech: true, stronghold: false }, + { rules: 'advanced', tech: false, stronghold: true }, + { rules: 'advanced', tech: true, stronghold: true }, +]; + +void test('all six standalone Ecaz bands draw at a genuine closing Nexus and spend living unclaimed Duke once without native Moritani', () => { + for (const options of profiles) { + const f = fixture(options); + assert.equal(f.offered.setupStage, 'leaderSkills'); + assert.equal(f.offered.players.flatMap(p => p.traitors).length, 0); + assert.equal(f.offered.players.flatMap(p => p.traitorChoices).length, 0); + assert.equal(f.afterSetup.players.some(p => p.faction === 'moritani'), false); + assert.equal(f.afterFirstStorm.techTokens ? ownedTech(f.afterFirstStorm.techTokens, f.owner).length : 0, options.tech ? 1 : 0); + if (options.stronghold) { + assert.equal(f.beforeFirstMentat.strongholdCards!.owners[f.territory], null); + assert.equal(f.afterFirstMentat.strongholdCards!.owners[f.territory], f.owner); + } + assert.equal(f.closingDraw.before.nexusCards!.cards!.hands[f.owner], null); + assert.ok(f.closingDraw.before.nexusCards!.phase!.eligible.includes(f.owner)); + assert.equal(player(f.closingDraw.before, f.opponent).ally, f.observer); + assert.equal(player(f.closingDraw.before, f.observer).ally, f.opponent); + assert.equal(player(f.closingDraw.before, f.owner).ally, null); + const drawn = step(f.closingDraw.before, f.closingDraw.step); + assert.equal(drawn.nexusCards!.cards!.hands[f.owner], 'ecaz'); + assert.equal(drawn.nexusCards!.cards!.deck.includes('ecaz'), false); + custody(drawn); + assert.equal(f.closingDraw.after.nexusCards!.cards!.hands[f.owner], 'ecaz'); + assert.equal(f.beforeCunning.dukeVidal!.controller, null); + assert.equal(f.beforeCunning.dukeVidal!.leader.dead, false); + assert.equal(viewGame(f.beforeCunning, f.opponent).nexusEcazDuke, null); + assert.equal(viewGame(f.beforeCunning, f.observer).nexusEcazDuke, null); + reject(f.beforeCunning, f.opponent, f.cunning.step.action); + reject(f.beforeCunning, f.owner, { type: 'nexusEcazDuke', event: `${viewGame(f.beforeCunning, f.owner).nexusEcazDuke!.event}-stale` }); + const acquired = step(f.beforeCunning, f.cunning.step); + assert.deepEqual(acquired, f.cunning.after); + assert.deepEqual([acquired.dukeVidal!.controller, acquired.dukeVidal!.source, acquired.dukeVidal!.acquiredTurn], [f.owner, 'ecazNexus', acquired.turn]); + assert.equal(acquired.nexusCards!.cards!.hands[f.owner], null); + assert.equal(acquired.nexusCards!.cards!.discard.filter(c => c === 'ecaz').length, 1); + assert.equal(acquired.nexusEcazDukeHistory!.length, 1); + assert.equal(acquired.nexusEcazDukeHistory![0].before.controller, null); + assert.deepEqual(acquired.leaderSkills, f.beforeCunning.leaderSkills); + assert.deepEqual(acquired.players.map(p => p.leaders), f.beforeCunning.players.map(p => p.leaders)); + assert.equal(player(acquired, f.owner).leaders.length, 5); + assert.ok(acquired.players.every(p => p.leaders.every(l => l.id !== DUKE_VIDAL_ID))); + assert.ok(viewGame(acquired, f.owner).players.find(p => p.id === f.owner)!.leaders.some(l => l.id === DUKE_VIDAL_ID)); + assert.ok(viewGame(acquired, f.owner).leaderSkills!.eligibleLeaders.every(l => l.id !== DUKE_VIDAL_ID)); + reject(acquired, f.owner, f.cunning.step.action); + custody(acquired); + } +}); + +void test('normal Warmaster discipline composes real Nexus Duke6, winner cleanup and mandatory original Tech; selected training never transfers to Duke', () => { + for (const options of profiles) for (const band of ['normal', 'skilled'] as const) { + const f = fixture({ ...options, band }); + const assigned = structuredClone(trainer(f.game, f.owner)), native = structuredClone(player(f.game, f.owner).leaders); + const revealed = reveal(f.game, f.plans), source = facts(revealed, f.owner, f.opponent); + const winner = band === 'normal' ? f.owner : f.opponent; + let game = settle(revealed, 'cards'); + assert.equal(game.lastBattleContext!.winner, winner); + const winnerUsedCard = f.plans.find(plan => plan.actor === winner)!.action.weapon; + if (winnerUsedCard) { + assert.equal(game.decision?.kind, 'battleCards'); + assert.equal(game.decision?.player, winner); + } + assert.equal(game.dukeVidal!.leader.dead, false); + assert.equal(game.dukeVidal!.leader.usedAt, f.territory); + assert.deepEqual([game.dukeVidal!.controller, game.dukeVidal!.source, game.dukeVidal!.acquiredTurn], [null, null, null]); + assert.deepEqual(player(game, f.owner).leaders, native); + assert.deepEqual(trainer(game, f.owner), assigned); + assert.equal(player(game, f.owner).tanks, winner === f.owner ? 0 : 8); + const enemyDial = f.plans[1].action.dial as number; + assert.equal(player(game, f.opponent).tanks, winner === f.owner ? 8 : enemyDial); + if (winnerUsedCard) { + if (game.techTokens) { + const loser = winner === f.owner ? f.opponent : f.owner; + const token = ownedTech(revealed.techTokens, loser)[0]; assert.ok(token); + reject(game, winner, { type: 'decision', token }); + assert.equal(game.techTokens[token].owner, loser); + } + game = settle(act(game, winner, { type: 'decision', discard: [] })); + } + if (game.techTokens) { + const loser = winner === f.owner ? f.opponent : f.owner; + for (const token of ownedTech(revealed.techTokens, loser)) assert.equal(game.techTokens[token].owner, winner); + } + const worthless = source.ownHand.find(c => c.kind === 'worthless')!.id; + assert.equal(player(game, f.owner).hand.some(c => c.id === worthless), winner === f.owner); + assert.equal(game.discard.some(c => c.id === worthless), winner !== f.owner); + assert.equal(game.battle, null); + if (game.strongholdCards) { + assert.equal(game.strongholdCards.owners[f.territory], f.owner, 'Held cards do not transfer at battle resolution.'); + assert.equal(controls(game)[f.territory], winner, 'Current sole physical control is independent of retained card custody.'); + } + const afterExpiry = expire(game); + assert.equal(afterExpiry.dukeVidal!.leader.usedAt, undefined); + assert.equal(afterExpiry.dukeVidal!.controller, null); + assert.deepEqual(trainer(afterExpiry, f.owner), assigned); + if (game.strongholdCards) assert.equal(afterExpiry.strongholdCards!.owners[f.territory], winner); + custody(afterExpiry); + } +}); + +void test('normal Suk on the actual Duke rescues one real dial casualty with and without held Arrakeen subsidy', () => { + for (const options of profiles) { + const f = fixture({ ...options, skill: 'suk-graduate', band: 'normal', ownerDial: 5, opponentDial: 0 }); + const revealed = reveal(f.game, f.plans), source = facts(revealed, f.owner, f.opponent); + let game = settle(revealed, 'cards'); + assert.equal(game.lastBattleContext!.winner, f.owner); + assert.equal(game.pendingSukRescue, null, 'The face-up normal skill is automatic and never borrows the selected-disc three-force rescue.'); + assert.equal(player(game, f.owner).forces[f.location], 3); + assert.equal(player(game, f.owner).reserves, source.ownReserves + 1); + assert.equal(player(game, f.owner).tanks, 4); + assert.equal(player(game, f.opponent).tanks, 8); + assert.equal(player(game, f.owner).spice, source.ownWallet - (f.game.advanced ? options.stronghold ? 3 : 5 : 0)); + assert.equal(game.dukeVidal!.leader.dead, false); + assert.equal(game.dukeVidal!.leader.usedAt, f.territory); + assert.equal(game.dukeVidal!.controller, null); + assert.deepEqual(trainer(game, f.owner), source.assignment); + game = settle(act(game, f.owner, { type: 'decision', discard: [] })); + for (const token of source.enemyTech) assert.equal(game.techTokens![token].owner, f.owner); + custody(game); + } +}); + +void test('ordinary trained Ecaz Suk keeps one casualty locally and returns two, while unused Nexus Duke expires only at genuine turn end', () => { + for (const options of profiles) { + const f = fixture({ ...options, kind: 'suk', band: 'skilled' }); + const revealed = reveal(f.game, f.plans), source = facts(revealed, f.owner, f.opponent); + let game = settle(revealed, 'suk'); + assert.equal(game.decision?.kind, 'sukRescue'); + if (game.decision?.kind !== 'sukRescue') throw Error('Expected the original selected-disc rescue.'); + assert.deepEqual(game.pendingSukRescue!.losses, { normal: 5, elite: 0, paidNormal: game.advanced ? 5 : 0, paidElite: 0 }); + assert.equal(game.dukeVidal!.controller, f.owner); + assert.equal(game.dukeVidal!.source, 'ecazNexus'); + assert.equal(game.dukeVidal!.leader.usedAt, undefined); + const event = game.decision.event; + reject(game, f.opponent, { type: 'decision', event, choice: 0 }); + reject(game, f.owner, { type: 'decision', event: `${event}-stale`, choice: 0 }); + reject(game, f.owner, { type: 'decision', event, choice: game.decision.options.length }); + const choice = game.decision.options.findIndex(o => o.normal === 3 && o.elite === 0 && o.kept?.key === f.location); assert.ok(choice >= 0); + game = settle(act(game, f.owner, { type: 'decision', event, choice }), 'cards'); + assert.equal(player(game, f.owner).forces[f.location], 4); + assert.equal(player(game, f.owner).reserves, source.ownReserves + 2); + assert.equal(player(game, f.owner).tanks, 2); + assert.equal(game.pendingSukRescue, null); + assert.equal(game.lastBattleContext!.sukRescue!.completed, true); + for (const token of source.enemyTech) assert.equal(game.techTokens![token].owner, f.opponent); + game = settle(act(game, f.owner, { type: 'decision', discard: [] })); + assert.equal(game.dukeVidal!.controller, f.owner, 'Winning with an ordinary disc does not consume the unused shared Duke.'); + assert.deepEqual(trainer(game, f.owner), source.assignment); + const afterExpiry = expire(game); + assert.deepEqual([afterExpiry.dukeVidal!.controller, afterExpiry.dukeVidal!.source, afterExpiry.dukeVidal!.acquiredTurn], [null, null, null]); + assert.equal(afterExpiry.dukeVidal!.leader.dead, false); + assert.equal(afterExpiry.nexusEcazDukeHistory!.length, 1); + assert.equal(afterExpiry.nexusCards!.cards!.discard.filter(c => c === 'ecaz').length, 1); + custody(afterExpiry); + } +}); + +void test('retained Carthag supplies real Duke Shield-as-Snooper, and losing Tuek income and printed Duke6 bounty precede original winner cleanup', () => { + const safe = fixture({ strongholdKind: 'carthag', poison: true, defense: true }); + const shield = player(safe.game, safe.owner).hand.find(c => c.kind === 'shield')!.id; + const defended = settle(reveal(safe.game, safe.plans)); + assert.equal(defended.lastBattleContext!.winner, safe.owner); + assert.equal(defended.dukeVidal!.leader.dead, false); + assert.equal(player(defended, safe.owner).hand.some(c => c.id === shield), true); + assert.equal(defended.strongholdCards!.owners.carthag, safe.owner); + custody(defended); + const f = fixture({ strongholdKind: 'tueks_sietch', poison: true, opponentDial: 0 }); + const revealed = reveal(f.game, f.plans), source = facts(revealed, f.owner, f.opponent); + let game = settle(revealed, 'cards'); + assert.equal(game.lastBattleContext!.winner, f.opponent); + assert.equal(game.dukeVidal!.leader.dead, true); + assert.equal(game.dukeVidal!.leader.deaths, 1); + assert.equal(player(game, f.owner).spice, source.ownWallet + 2); + assert.equal(player(game, f.opponent).spice, source.enemyWallet + 6); + assert.equal(player(game, f.owner).tanks, 8); + assert.deepEqual(player(game, f.owner).leaders, source.nativeLeaders); + assert.deepEqual(trainer(game, f.owner), source.assignment); + assert.equal(game.strongholdCards!.owners.tueks_sietch, f.owner); + const weapon = source.enemyHand.find(c => c.kind === 'poison')!.id; + const worthless = source.ownHand.find(c => c.kind === 'worthless')!.id; + game = settle(act(game, f.opponent, { type: 'decision', discard: [] })); + assert.equal(game.discard.some(c => c.id === worthless), true); + assert.equal(player(game, f.opponent).hand.some(c => c.id === weapon), true); + for (const token of ownedTech(revealed.techTokens, f.owner)) assert.equal(game.techTokens![token].owner, f.opponent); + custody(game); +}); + +void test('held Habbanya wins the real Duke tie and Tabr pays the actual opposing dial independently of cleanup and current control', () => { + const tie = fixture({ strongholdKind: 'habbanya_ridge_sietch', opponentDial: 2 }); + const tied = settle(reveal(tie.game, tie.plans), 'cards'); + assert.equal(tied.lastBattleContext!.winner, tie.owner); + assert.equal(player(tied, tie.owner).forces[tie.location], 8); + assert.equal(player(tied, tie.opponent).tanks, 8); + assert.equal(tied.strongholdCards!.owners.habbanya_ridge_sietch, tie.owner); + assert.equal(controls(tied).habbanya_ridge_sietch, tie.owner); + const completeTie = settle(act(tied, tie.owner, { type: 'decision', discard: [] })); + custody(completeTie); + const f = fixture({ strongholdKind: 'sietch_tabr' }); + const revealed = reveal(f.game, f.plans), source = facts(revealed, f.owner, f.opponent); + const opposingDial = f.plans[1].action.dial as number; + let game = settle(revealed, 'cards'); + assert.equal(game.lastBattleContext!.winner, f.owner); + assert.equal(player(game, f.owner).spice, source.ownWallet + opposingDial); + assert.equal(player(game, f.opponent).spice, source.enemyWallet - opposingDial); + assert.equal(player(game, f.owner).forces[f.location], 8); + assert.equal(player(game, f.opponent).tanks, 8); + assert.equal(game.dukeVidal!.controller, null); + assert.equal(game.dukeVidal!.leader.usedAt, f.territory); + assert.deepEqual(trainer(game, f.owner), source.assignment); + for (const token of source.enemyTech) assert.equal(game.techTokens![token].owner, f.opponent); + game = settle(act(game, f.owner, { type: 'decision', discard: [] })); + for (const token of source.enemyTech) assert.equal(game.techTokens![token].owner, f.owner); + custody(game); +}); + +void test('all four minimal legal policies execute native Cunning, trained rescue, winner cards and original Tech without inventing a plan', () => { + const f = fixture({ kind: 'suk', band: 'skilled' }); + const revealed = reveal(f.game, f.plans), rescue = settle(revealed, 'suk'); + for (const difficulty of DIFFICULTIES) { + const cunning = act(f.beforeCunning, f.owner, policy(f.beforeCunning, f.owner, difficulty)); + assert.equal(cunning.dukeVidal!.controller, f.owner); + assert.equal(cunning.nexusCards!.cards!.hands[f.owner], null); + let game = settle(act(rescue, f.owner, policy(rescue, f.owner, difficulty)), 'cards'); + assert.equal(game.pendingSukRescue, null); + assert.equal(player(game, f.owner).forces[f.location], 4); + assert.equal(player(game, f.owner).tanks, 2); + assert.equal(player(game, f.owner).reserves, player(revealed, f.owner).reserves + 2); + game = settle(act(game, f.owner, policy(game, f.owner, difficulty))); + assert.equal(game.battle, null); + assert.equal(game.lastBattleContext!.winner, f.owner); + for (const token of ownedTech(revealed.techTokens, f.opponent)) assert.equal(game.techTokens![token].owner, f.owner); + assert.deepEqual(trainer(game, f.owner), trainer(revealed, f.owner)); + custody(game); + } +}); + +void test('each of the five genuine native discs may take its actual offered skill, but foreign discs and Duke cannot be assigned', () => { + const offered = initialize({ tech: false, stronghold: false }); + const owner = offered.players.find(p => p.faction === 'ecaz')!.id; + const offer = offered.leaderSkills!.offers[owner]; + assert.ok(offer.cards.includes('warmaster')); + const native = player(offered, owner).leaders; + assert.equal(native.length, 5); + for (const disc of native) { + const game = complete(offered, { leader: disc.id }); + assert.equal(trainer(game, owner).leader, disc.id); + assert.equal(trainer(game, owner).skill, 'warmaster'); + custody(game); + } + for (const leader of [DUKE_VIDAL_ID, offered.players.find(p => p.id !== owner)!.leaders[0].id]) + reject(offered, owner, { type: 'leaderSkill', event: offer.event, skill: 'warmaster', leader }); + const absent = ['warmaster', 'suk-graduate', 'sandmaster', 'planetologist'] as const; + const skill = absent.find(c => !offer.cards.includes(c)); assert.ok(skill); + const before = structuredClone(offered); + assert.throws(() => complete(offered, { skill })); + assert.deepEqual(offered, before, 'Original own-seat offers cannot be redealt to satisfy a fixture request.'); +}); + +void test('quiet-boundary custody guards remain pure and reject receipt creation; exceptional recovery is not staged as played history', () => { + const f = fixture({ tech: false, stronghold: false }); + const g = f.beforeCunning, duke = g.dukeVidal!, event = viewGame(g, f.owner).nexusEcazDuke!.event; + // Quote-only hypothetical inputs, NOT accepted games, fabricated acquisitions, + // resurrected leaders or completed receipts. Actual runtime sources above are + // unchanged; each rejected alternative must leave the physical card unspent. + const alternatives: Game[] = [ + { ...g, dukeVidal: { ...duke, leader: { ...duke.leader, dead: true } } }, + { ...g, dukeVidal: { ...duke, leader: { ...duke.leader, capturedBy: f.opponent } } }, + { ...g, dukeVidal: { ...duke, leader: { ...duke.leader, gholaBy: f.opponent } } }, + { ...g, dukeVidal: { ...duke, leader: { ...duke.leader, usedAt: f.territory } } }, + { ...g, dukeVidal: { ...duke, controller: f.owner } }, + { ...g, players: g.players.map(p => p.id === f.owner ? { ...p, ally: f.opponent } : p) }, + { ...g, phase: 5 }, + { ...g, battle: f.game.battle }, + ]; + for (const input of alternatives) { + assert.ok(quoteNexusEcazDuke(input, f.owner)?.blocked); + assert.throws(() => createNexusEcazDukeReceipt(input, f.owner)); + assert.equal(g.nexusCards!.cards!.hands[f.owner], 'ecaz'); + assert.equal(g.nexusEcazDukeHistory?.length ?? 0, 0); + } + reject(g, f.owner, { type: 'nexusEcazDuke', event: `${event}-stale` }); + reject(f.game, f.owner, f.cunning.step.action); + custody(g); +}); + +void test('original human lobby and setup IDs/offer stock survive continuation; two-seat play never fabricates an unallied closing draw', () => { + const initial = createGame('HUMANECAZNEXUS', newPlayer('human-ecaz', 'Human Ecaz', 'ecaz'), true, ['ecaz']); + joinGame(initial, newPlayer('human-emperor', 'Human Emperor', 'emperor')); + joinGame(initial, newPlayer('human-atreides', 'Human Atreides', 'atreides')); + const before = structuredClone(initial), f = fixture({ initial, tech: true, stronghold: true }); + assert.deepEqual(initial, before); + assert.deepEqual(f.afterSetup.players.map(p => p.id), initial.players.map(p => p.id)); + assert.equal(f.owner, 'human-ecaz'); assert.equal(f.opponent, 'human-emperor'); + assert.deepEqual(initialize({ initial: f.offered }), f.offered); + custody(f.game); + const two = createGame('TWOECAZNEXUS', newPlayer('human-two-ecaz', 'Human Ecaz', 'ecaz'), true, ['ecaz']); + joinGame(two, newPlayer('human-two-emperor', 'Human Emperor', 'emperor')); + const offered = initialize({ initial: two, tech: false, stronghold: true }); + const game = complete(offered); + assert.equal(game.players.length, 2); + assert.ok(game.players.every(p => game.nexusCards!.cards!.hands[p.id] === null)); + custody(game); + assert.throws(() => fixture({ initial: two, tech: false, stronghold: true })); + assert.throws(() => initialize({ initial: two, tech: true, stronghold: true })); + const basic = createGame('BASICECAZNEXUS', newPlayer('b-ecaz', 'Ecaz', 'ecaz'), false, ['ecaz']); + joinGame(basic, newPlayer('b-emperor', 'Emperor', 'emperor')); + assert.throws(() => initialize({ initial: basic, tech: false, stronghold: true })); + for (const faction of ['harkonnen', 'moritani', 'ixians', 'choam'] as const) { + const expansions = faction === 'ixians' ? ['ecaz', 'ix'] : faction === 'choam' ? ['ecaz', 'choam'] : ['ecaz']; + const unsupported = createGame('BOUNDARYECAZNEXUS', newPlayer('e', 'Ecaz', 'ecaz'), true, expansions); + joinGame(unsupported, newPlayer('x', faction, faction)); + assert.throws(() => initialize({ initial: unsupported, tech: false, stronghold: false })); + } + assert.throws(() => initialize({ initial: f.afterSetup })); + const basicHark = createGame('BASICHARKECAZNEXUS', newPlayer('e', 'Ecaz', 'ecaz'), false, ['ecaz']); + joinGame(basicHark, newPlayer('h', 'Harkonnen', 'harkonnen')); + joinGame(basicHark, newPlayer('a', 'Atreides', 'atreides')); + const nativeBasic = complete(initialize({ initial: basicHark, tech: false, stronghold: false })); + assert.equal(nativeBasic.players.find(p => p.faction === 'harkonnen')!.traitors.length, 4); + custody(nativeBasic); +}); + +void test('original six-seat Basic and Advanced Skills/Nexus retain full native inventories with unused optional module bands', () => { + for (const advanced of [false, true]) { + const initial = createGame('SIXECAZNEXUS', newPlayer('six-ecaz', 'Ecaz', 'ecaz'), advanced, ['ecaz']); + for (const faction of ['atreides', 'emperor', 'guild', 'fremen', 'beneGesserit'] as const) + joinGame(initial, newPlayer(`six-${faction}`, faction, faction)); + const offered = initialize({ initial, tech: true, stronghold: advanced }); + const game = complete(offered); + assert.equal(game.leaderSkills!.assignments.length, 6); + assert.equal(player(game, 'six-ecaz').leaders.length, 5); + if (advanced) { + for (const [actor, total] of [['six-fremen', 3], ['six-emperor', 5]] as const) { + const elites = player(game, actor).elites; assert.ok(elites); + assert.equal(elites.reserves + elites.tanks + Object.values(elites.forces).reduce((sum, n) => sum + n, 0), total); + } + } else assert.ok(game.players.every(p => !p.elites)); + const storm = advance(game, g => g.phase === 1 && !g.response && !g.phaseOpening && !g.decision); + assert.equal(storm.techTokens!.production.owner, 'six-fremen'); + const owners = Object.values(storm.techTokens!).map(token => token.owner); + assert.ok(owners.every(owner => owner !== null && storm.players.some(p => p.id === owner))); + assert.equal(new Set(owners).size, 3, 'Actual first Storm assigns the remaining tokens to distinct native seats, not to all six players.'); + custody(storm); + } +}); diff --git a/tests/ecaz-nexus-skills-occupy-runtime.test.ts b/tests/ecaz-nexus-skills-occupy-runtime.test.ts new file mode 100644 index 00000000..8b7c27cc --- /dev/null +++ b/tests/ecaz-nexus-skills-occupy-runtime.test.ts @@ -0,0 +1,118 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, viewGame, type Game } from '../game/engine'; +import { + advanceEcazNexusSkillsModules as advance, assertEcazNexusSkillsModulesCustody as custody, + createEcazNexusSkillsModulesFixture as fixture, ecazNexusSkillsModulesClean as clean, + ecazNexusSkillsModulesPlayer as player, holdEcazNexusSkillsModulesCard as hold, + nextEcazNexusSkillsModulesStep as next, placeEcazNexusSkillsModulesForce as place, + revealEcazNexusSkillsModulesBattle as reveal, settleEcazNexusSkillsModulesBattle as settle, + stepEcazNexusSkillsModules as step, +} from './fixture-ecaz-nexus-skills-modules'; + +function coalition(tech: boolean, stronghold: boolean, trained: boolean, + skill: 'suk-graduate' | 'diplomat' = 'suk-graduate', losing = false) { + const f = fixture({ rules: 'advanced', tech, stronghold, skill, kind: 'suk', band: trained ? 'skilled' : 'normal' }); + let game = structuredClone(f.beforeAlliance); + // A real alternate Nexus alliance, not a post-Battle rewritten side. + game = applyAction(game, f.owner, { type: 'alliance', target: f.observer }); + game = applyAction(game, f.observer, { type: 'alliance', target: f.owner }); + game = advance(game, g => g.phase === 5 && clean(g)); + const staging: string[] = []; + for (const p of game.players) { + p.reserves += Object.values(p.forces).reduce((sum, amount) => sum + amount, 0); + p.forces = {}; + if (p.elites) { + p.elites.reserves += Object.values(p.elites.forces).reduce((sum, amount) => sum + amount, 0); + p.elites.forces = {}; + } + game.deck.push(...p.hand); p.hand = []; + } + // Conserved original counters/cards only. No phase, wallet, skill or receipt assigned. + const location = 'arrakeen:10'; + place(game, f.owner, location, 5, staging); + place(game, f.observer, location, 4, staging); + place(game, f.opponent, location, 8, staging); + const worthless = hold(game, f.owner, c => c.kind === 'worthless', staging); + game = advance(game, g => g.phase === 6 && clean(g)); + const choice = viewGame(game, game.active!).battleChoices.find(b => b.territory === 'arrakeen'); + assert.ok(choice); + game = applyAction(game, choice.chooser, { type: 'chooseBattle', territory: 'arrakeen', + target: choice.attacker === choice.chooser ? choice.defender : choice.attacker }); + assert.equal(game.decision?.kind, 'ecazBattleLead'); + game = applyAction(game, f.owner, { type: 'decision', event: game.decision!.event, lead: f.owner }); + for (let n = 0; n < 100; n++) { + if (game.battle && clean(game) && !game.battle.preparation) break; + const action = next(game); assert.ok(action); + if (action.action.type === 'leaderSkillVisibility' && action.actor === f.owner) action.action.hide = trained; + game = step(game, action); + } + assert.ok(game.battle && clean(game) && !game.battle.preparation); + const own = player(game, f.owner), enemy = player(game, f.opponent); + const leader = trained ? f.trainer : own.leaders.find(l => l.id !== f.trainer && l.strength === 4 && !l.dead)!.id; + const opponentLeader = enemy.leaders.find(l => l.strength === 6 && !l.dead)!.id; + const before = structuredClone(game); + game = reveal(game, [ + { actor: f.owner, action: { type: 'battlePlan', leader, dial: 5, support: 2, weapon: worthless.id } }, + { actor: f.opponent, action: { type: 'battlePlan', leader: opponentLeader, dial: losing ? 8 : 0, support: losing ? 8 : 0 } }, + ]); + return { f, before, game, location }; +} + +void test('Ecaz-led Occupy normal Suk redirects its own fixed casualty, never the ally variable pool', () => { + for (const [tech, stronghold] of [[false, false], [true, false], [false, true], [true, true]]) { + const c = coalition(tech, stronghold, false); + const game = settle(c.game, 'cards'); + assert.equal(game.lastBattleContext!.winner, c.f.owner); + assert.equal(player(game, c.f.owner).tanks - player(c.before, c.f.owner).tanks, 2); + assert.equal(player(game, c.f.owner).reserves - player(c.before, c.f.owner).reserves, 1); + assert.equal(player(game, c.f.owner).forces[c.location], 2); + assert.equal(player(game, c.f.observer).tanks - player(c.before, c.f.observer).tanks, 2); + assert.equal(player(game, c.f.observer).forces[c.location], 2); + assert.equal(game.pendingSukRescue, null); + custody(game); + } +}); + +void test('Ecaz-led Occupy trained Suk saves its fixed three after original ally losses and survives JSON continuation', () => { + const c = coalition(true, true, true); + let game = settle(c.game, 'suk'); + assert.equal(game.decision?.kind, 'sukRescue'); + assert.equal(player(game, c.f.observer).tanks - player(c.before, c.f.observer).tanks, 2); + assert.equal(player(game, c.f.owner).forces[c.location], 5); + const choice = game.decision!; + assert.equal(choice.kind, 'sukRescue'); + if (choice.kind !== 'sukRescue') throw Error('Expected real rescue'); + const option = choice.options.findIndex(o => o.normal === 3); assert.ok(option >= 0); + game = applyAction(JSON.parse(JSON.stringify(game)) as Game, c.f.owner, + { type: 'decision', event: choice.event, choice: option }); + assert.equal(player(game, c.f.owner).tanks, player(c.before, c.f.owner).tanks); + assert.equal(player(game, c.f.owner).reserves - player(c.before, c.f.owner).reserves, 2); + assert.equal(player(game, c.f.owner).forces[c.location], 3); + assert.equal(player(game, c.f.observer).forces[c.location], 2); + assert.equal(game.lastBattleContext!.sukRescue!.completed, true); + custody(game); +}); + +void test('Ecaz-led Occupy Diplomat retreats only its own undialed remainder, not the ally dial', () => { + const c = coalition(true, true, true, 'diplomat', true); + let game = advance(c.game, g => g.decision?.kind === 'diplomatRetreat' || !g.battle && clean(g)); + assert.equal(game.decision?.kind, 'diplomatRetreat'); + if (game.decision?.kind !== 'diplomatRetreat') throw Error('Expected actual own-force retreat'); + const decision = game.decision; + const destination = decision.destinations.find(d => d.choices.some(o => o.normal === 2 && o.elite === 0)); + assert.ok(destination); + const before = structuredClone(game); + assert.throws(() => applyAction(game, c.f.owner, { type: 'decision', event: decision.event, + destination: destination.location, normal: 3, elite: 0 })); + assert.deepEqual(game, before); + game = applyAction(JSON.parse(JSON.stringify(game)) as Game, c.f.owner, + { type: 'decision', event: decision.event, destination: destination.location, normal: 2, elite: 0 }); + game = settle(game); + assert.equal(player(game, c.f.owner).forces[destination.location], 2); + assert.equal(player(game, c.f.owner).forces[c.location], undefined); + assert.equal(player(game, c.f.owner).tanks - player(c.before, c.f.owner).tanks, 3); + assert.equal(player(game, c.f.observer).tanks - player(c.before, c.f.observer).tanks, 4); + assert.equal(game.lastBattleContext!.winner, c.f.opponent); + custody(game); +}); diff --git a/tests/fixture-ecaz-nexus-skills-modules.ts b/tests/fixture-ecaz-nexus-skills-modules.ts new file mode 100644 index 00000000..e32f8fcb --- /dev/null +++ b/tests/fixture-ecaz-nexus-skills-modules.ts @@ -0,0 +1,354 @@ +import assert from 'node:assert/strict'; +import { applyAction, createGame, handLimit, joinGame, newPlayer, viewGame, type Game } from '../game/engine'; +import { territory } from '../game/board'; +import type { FactionId } from '../game/catalog'; +import { DUKE_VIDAL_ID } from '../game/duke-vidal'; +import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards'; +import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards'; +import { strongholdControllers, type StrongholdId } from '../game/stronghold-cards'; +import { ownedTech } from '../game/tech-tokens'; +import type { Card } from '../game/cards'; +import { initializeAdvancedNativeSkillsSetup } from './fixture-advanced-native-skills'; +import { + assertEcazSkillsModulesCustody, ecazSkillsModulesAction, ecazSkillsModulesPlayer, + ecazSkillsModulesTrainer, nextEcazSkillsModulesStep, reloadEcazSkillsModules, +} from './fixture-ecaz-skills-modules'; +import type { StrongholdFactionsNativeStep } from './fixture-stronghold-factions'; + +/** Existing source composition, not an exceptional-Duke or allied-Occupy ruling. + * GF9 E3 pp.3/8/11/16 and publisher Ecaz Nexus face: docs/NEXUS_ECAZ_RULES.md. + * All14 normal/selected-disc effects: docs/LEADER_SKILLS_RULES.md. + * Original held-card effects/lifecycle: docs/STRONGHOLD_CARDS.md. + * The quiet living/unclaimed Duke window is the existing product boundary; + * printed dead/captured/Ghola recovery and shared-Duke training remain pending. + */ +export const ECAZ_NEXUS_SKILLS_MODULES_SOURCES = { + nexus: 'docs/NEXUS_ECAZ_RULES.md#bounded-native-cunning-duke-vidal', + skills: 'docs/LEADER_SKILLS_RULES.md', + modules: 'docs/STRONGHOLD_CARDS.md', + publisher: 'https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf', + face: 'https://boardgamegeek.com/image/7767032/dune-ecaz-and-moritani', +} as const; +export type EcazNexusSkillsModulesStep = StrongholdFactionsNativeStep; +export type EcazNexusSkillsModulesOptions = { + /** Only the actual undealt lobby or original unassigned skill-first setup. */ + initial?: Game; + rules?: 'basic' | 'advanced'; + tech?: boolean; + stronghold?: boolean; + skill?: LeaderSkillId; + /** A genuine eligible native disc; never the separate Duke. */ + leader?: string; + kind?: 'duke' | 'suk'; + band?: 'normal' | 'skilled'; + strongholdKind?: Exclude; + poison?: boolean; + defense?: boolean; + ownerDial?: number; + opponentDial?: number; +}; +export type EcazNexusSkillsModulesTransition = { + before: Game; step: EcazNexusSkillsModulesStep; after: Game; +}; +export type EcazNexusSkillsModulesFixture = { + initial: Game; offered: Game; afterSetup: Game; afterFirstStorm: Game; + beforeFirstMentat: Game; firstMentatStep: EcazNexusSkillsModulesStep; afterFirstMentat: Game; + beforeAlliance: Game; allianceActions: EcazNexusSkillsModulesStep[]; + closingDraw: EcazNexusSkillsModulesTransition; + beforeCunning: Game; cunning: EcazNexusSkillsModulesTransition; + beforeBattle: Game; posture: Game; game: Game; + owner: string; opponent: string; observer: string; trainer: string; + territory: Exclude; location: string; + plans: EcazNexusSkillsModulesStep[]; actions: EcazNexusSkillsModulesStep[]; staging: string[]; +}; +export { + ecazSkillsModulesAction as ecazNexusSkillsModulesAction, + ecazSkillsModulesPlayer as ecazNexusSkillsModulesPlayer, + ecazSkillsModulesTrainer as ecazNexusSkillsModulesTrainer, + reloadEcazSkillsModules as reloadEcazNexusSkillsModules, +}; +export const ecazNexusSkillsModulesClean = (g: Game): boolean => !g.response && !g.phaseOpening && !g.decision; + +export function assertEcazNexusSkillsModulesCustody(game: Game): void { + assertEcazSkillsModulesCustody(game); + const skills = game.leaderSkills!; + assert.deepEqual([...skills.deck, ...Object.values(skills.offers).flatMap(o => o.cards), + ...skills.assignments.map(a => a.skill)].sort(), LEADER_SKILL_CARDS.map(c => c.id).sort()); + validateNexusCards(game.nexusCards!.cards!, game.players); + const cards = game.nexusCards!.cards!; + assert.deepEqual([...cards.deck, ...cards.discard, + ...Object.values(cards.hands).filter((c): c is FactionId => c !== null)].sort(), [...NEXUS_FACTIONS].sort()); +} + +export function nextEcazNexusSkillsModulesStep(game: Game): EcazNexusSkillsModulesStep | null { + if (ecazNexusSkillsModulesClean(game) && game.nexusCards?.phase?.stage === 'drawing') { + const phase = game.nexusCards.phase, actor = phase.eligible.find(id => !phase.done.includes(id)); + if (actor) return { actor, action: { type: 'nexusCardChoice', turn: game.turn, + card: game.nexusCards.cards!.hands[actor], choice: 'keep', ownRedraws: 0 } }; + } + if (!game.response && !game.phaseOpening && game.decision?.kind === 'wormRide') + return { actor: game.decision.player, action: { type: 'decision', accept: false } }; + return nextEcazSkillsModulesStep(game); +} +export function stepEcazNexusSkillsModules(game: Game, next: EcazNexusSkillsModulesStep, + actions?: EcazNexusSkillsModulesStep[]): Game { + actions?.push(structuredClone(next)); + return ecazSkillsModulesAction(game, next.actor, next.action); +} +export function advanceEcazNexusSkillsModules(state: Game, until: (g: Game) => boolean, + actions?: EcazNexusSkillsModulesStep[], firstDial = 0): Game { + let game = reloadEcazSkillsModules(state); + for (let count = 0; count < 1800; count++) { + if (until(game)) return game; + assert.equal(game.status, 'playing'); + const next = nextEcazNexusSkillsModulesStep(game); + assert.ok(next, 'An actual private battle plan must be supplied by its owner.'); + if (game.turn === 1 && next.action.type === 'stormDial') next.action.amount = firstDial; + game = stepEcazNexusSkillsModules(game, next, actions); + } + throw Error('Original Ecaz Nexus/Skills chronology did not reach its declared boundary.'); +} + +const CLASSIC_OPPONENTS: Partial> = { + atreides: true, emperor: true, guild: true, fremen: true, beneGesserit: true, harkonnen: true, +}; +function lobby(options: EcazNexusSkillsModulesOptions): Game { + const rules = options.rules ?? (options.initial?.advanced === false ? 'basic' : 'advanced'); + let game = options.initial ? reloadEcazSkillsModules(options.initial) + : createGame('ECAZNEXUSSKILLSMODULES', newPlayer('ecaz', 'Ecaz', 'ecaz'), rules === 'advanced', ['ecaz']); + if (!options.initial) for (const faction of ['emperor', 'atreides'] as const) joinGame(game, newPlayer(faction, faction, faction)); + const tech = options.tech ?? (options.initial ? !!game.techTokens : true); + const stronghold = options.stronghold ?? (options.initial ? !!game.strongholdCards : rules === 'advanced'); + assert.equal(game.advanced, rules === 'advanced'); + assert.deepEqual(game.expansions, ['ecaz']); + assert.ok(game.players.length >= (tech ? 3 : 2) && game.players.length <= 6); + assert.ok(!stronghold || game.advanced); + assert.equal(game.players.filter(p => p.faction === 'ecaz').length, 1); + assert.ok(game.players.every(p => p.faction === 'ecaz' || + CLASSIC_OPPONENTS[p.faction] === true && (!game.advanced || p.faction !== 'harkonnen'))); + assert.ok(game.status === 'lobby' || game.status === 'setup' && game.turn === 1 && game.phase === 0 && + game.setupStage === 'leaderSkills' && game.leaderSkills?.assignments.length === 0, + 'Continue only an original undealt lobby or unassigned skill-first setup.'); + if (game.status === 'lobby') { + if (!!game.techTokens !== tech) game = applyAction(game, game.host, { type: 'techTokens', enabled: tech }); + if (!!game.strongholdCards !== stronghold) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: stronghold }); + // Same unused lobby-module declaration as the original Nexus/Skills fixtures. + if (!game.nexusCards) game.nexusCards = { cards: null, phase: null }; + } else { + assert.equal(!!game.techTokens, tech); assert.equal(!!game.strongholdCards, stronghold); + assert.ok(game.nexusCards, 'Do not convert a dealt setup into a Nexus game.'); + } + return game; +} +export function initializeEcazNexusSkillsModules(options: EcazNexusSkillsModulesOptions = {}): Game { + const game = lobby(options); + return initializeAdvancedNativeSkillsSetup({ family: 'ecaz', initial: game, + rules: game.advanced ? 'advanced' : 'basic', requestedSkill: options.skill ?? (options.kind === 'suk' ? 'suk-graduate' : 'warmaster') }); +} +/** Original own-seat offers/IDs are authoritative; a requested missing skill is + * an error, never a redeal, a fallback assignment or a forged selected effect. */ +export function completeEcazNexusSkillsModulesSetup(state: Game, options: EcazNexusSkillsModulesOptions = {}, + actions?: EcazNexusSkillsModulesStep[]): Game { + let game = reloadEcazSkillsModules(state); + const owner = game.players.find(p => p.faction === 'ecaz')!.id; + const requested = options.skill ?? (options.kind === 'suk' ? 'suk-graduate' : 'warmaster'); + for (let count = 0; game.status === 'setup' && count < 200; count++) { + if (game.setupStage === 'leaderSkills') { + const actor = Object.keys(game.leaderSkills!.offers)[0], own = viewGame(game, actor).leaderSkills!; + const offer = game.leaderSkills!.offers[actor]; + const skill = actor === owner ? requested : offer.cards.find(c => !own.unavailableSkills?.[c]); + const eligible = new Set(own.eligibleLeaders.map(l => l.id)); + const discs = ecazSkillsModulesPlayer(game, actor).leaders.filter(l => eligible.has(l.id)); + const leader = actor === owner && options.leader ? discs.find(l => l.id === options.leader) + : discs.sort((a, b) => b.strength - a.strength)[0]; + assert.ok(skill && offer.cards.includes(skill) && !own.unavailableSkills?.[skill] && leader); + game = stepEcazNexusSkillsModules(game, { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: leader.id } }, actions); + } else { + const next = nextEcazNexusSkillsModulesStep(game); assert.ok(next); + game = stepEcazNexusSkillsModules(game, next, actions); + } + } + assert.equal(game.status, 'playing'); + assertEcazNexusSkillsModulesCustody(game); + return game; +} +function reserveBoard(game: Game, staging: string[]): void { + for (const p of game.players) { + p.reserves += Object.values(p.forces).reduce((sum, n) => sum + n, 0); p.forces = {}; + if (p.elites) { p.elites.reserves += Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0); p.elites.forces = {}; } + if (p.advisors) p.advisors = {}; + } + staging.push('Conserve all native board counters into their own reserves; no wallet, phase, turn, module owner, skill, receipt or Duke custody is assigned.'); +} +export function placeEcazNexusSkillsModulesForce(game: Game, actor: string, location: string, amount: number, staging: string[]): void { + const p = ecazSkillsModulesPlayer(game, actor); + assert.ok(Number.isSafeInteger(amount) && amount >= 0 && p.reserves >= amount); + p.reserves -= amount; p.forces[location] = (p.forces[location] ?? 0) + amount; + staging.push(`Conserved ${amount} ordinary ${actor} counters from their own reserves to ${location}.`); +} +function orderSpice(game: Game, kinds: ('worm' | 'land')[], staging: string[]): void { + const front = kinds.map(kind => { + const index = game.spiceDeck.findIndex(c => kind === 'worm' ? 'worm' in c : 'territory' in c); assert.ok(index >= 0); + return game.spiceDeck.splice(index, 1)[0]; + }); + game.spiceDeck.unshift(...front); + staging.push(`Order only existing unplayed original Spice Cards: ${kinds.join(', ')}; played discards remain unchanged.`); +} +export function holdEcazNexusSkillsModulesCard(game: Game, actor: string, predicate: (c: Card) => boolean, staging: string[]): Card { + const p = ecazSkillsModulesPlayer(game, actor), held = p.hand.find(predicate); + if (held) return held; + const index = game.deck.findIndex(predicate); assert.ok(index >= 0 && p.hand.length < handLimit(p)); + const card = game.deck.splice(index, 1)[0]; p.hand.push(card); + staging.push(`Conserved original Ecaz33 ${card.id} from the remaining physical deck to ${actor}'s hand.`); + return card; +} +export function ecazNexusSkillsModulesCunningRequest(game: Game, owner: string): EcazNexusSkillsModulesStep { + const offer = viewGame(game, owner).nexusEcazDuke; assert.ok(offer && offer.blocked === null); + return { actor: owner, action: { type: 'nexusEcazDuke', event: offer.event } }; +} +/** Stop before the actual human posture choice, never manufacture visibility. */ +export function openEcazNexusSkillsModulesPosture(game: Game, owner: string, opponent: string, + stronghold: string, actions?: EcazNexusSkillsModulesStep[]): Game { + assert.ok(game.phase === 6 && ecazNexusSkillsModulesClean(game) && !game.battle); + assert.ok(game.active === owner || game.active === opponent); + const opened = stepEcazNexusSkillsModules(game, { actor: game.active!, action: { + type: 'chooseBattle', territory: stronghold, target: game.active === owner ? opponent : owner } }, actions); + return advanceEcazNexusSkillsModules(opened, g => g.decision?.kind === 'leaderSkillVisibility', actions); +} +export function advanceEcazNexusSkillsModulesToPlans(game: Game, actions?: EcazNexusSkillsModulesStep[]): Game { + return advanceEcazNexusSkillsModules(game, g => !!g.battle && ecazNexusSkillsModulesClean(g) && + !g.battle.preparation && g.battle.preLeader?.closed !== false, actions); +} +export function revealEcazNexusSkillsModulesBattle(game: Game, plans: readonly EcazNexusSkillsModulesStep[], + actions?: EcazNexusSkillsModulesStep[]): Game { + let state = reloadEcazSkillsModules(game); + for (const plan of plans) { + state = stepEcazNexusSkillsModules(state, plan, actions); + if (!state.battle?.revealed) state = advanceEcazNexusSkillsModulesToPlans(state, actions); + } + assert.ok(state.battle?.revealed); + return state; +} +export function settleEcazNexusSkillsModulesBattle(game: Game, + stop: 'suk' | 'cards' | 'tech' | 'complete' = 'complete', actions?: EcazNexusSkillsModulesStep[]): Game { + return advanceEcazNexusSkillsModules(game, g => + stop === 'suk' && g.decision?.kind === 'sukRescue' || + stop === 'cards' && g.decision?.kind === 'battleCards' || + stop === 'tech' && g.decision?.kind === 'techToken' || + !g.battle && ecazNexusSkillsModulesClean(g) && !g.pendingTreacheryDiscard, actions); +} +/** Native immutable source facts: no computed/fabricated matching stock. */ +export function ecazNexusSkillsModulesSourceFacts(game: Game, owner: string, opponent: string) { + const p = ecazSkillsModulesPlayer(game, owner), enemy = ecazSkillsModulesPlayer(game, opponent); + return structuredClone({ + turn: game.turn, phase: game.phase, advanced: game.advanced, + duke: game.dukeVidal, assignment: ecazSkillsModulesTrainer(game, owner), + nativeLeaders: p.leaders, ownWallet: p.spice, enemyWallet: enemy.spice, + ownForces: p.forces, enemyForces: enemy.forces, ownReserves: p.reserves, + ownTanks: p.tanks, enemyTanks: enemy.tanks, ownHand: p.hand, enemyHand: enemy.hand, + heldCards: game.strongholdCards?.owners, controls: strongholdControllers(game.players, false), + enemyTech: ownedTech(game.techTokens, opponent), plans: game.battle?.plans, + losses: game.pendingSukRescue?.losses, battle: game.lastBattleContext, + }); +} +export const ecazNexusSkillsModulesCurrentControl = (game: Game) => strongholdControllers(game.players, false); +/** Genuine turn-end readiness, including ordinary set-aside clearing and unused + * Nexus tenure expiry. No phase/turn or custody jump is permitted. */ +export function expireEcazNexusSkillsModulesDuke(game: Game, actions?: EcazNexusSkillsModulesStep[]): Game { + const turn = game.turn; + return advanceEcazNexusSkillsModules(game, g => g.turn > turn, actions); +} + +/** Native setup -> first Storm -> actual END Mentat -> actual worm/alliance -> + * closing Ecaz draw -> quiet Battle -> living/unclaimed Cunning. Conserved + * staging is labeled below, not claimed to be natural random play history. + * Three seats are needed for a real alliance plus one unallied draw; two-seat + * original setup is supported by initialize/complete, never by a forged draw. */ +export function createEcazNexusSkillsModulesFixture(options: EcazNexusSkillsModulesOptions = {}): EcazNexusSkillsModulesFixture { + const actions: EcazNexusSkillsModulesStep[] = [], staging: string[] = []; + const initial = lobby(options), offered = initializeEcazNexusSkillsModules({ ...options, initial }); + let game = completeEcazNexusSkillsModulesSetup(offered, options, actions); + const afterSetup = reloadEcazSkillsModules(game); + const owner = game.players.find(p => p.faction === 'ecaz')!.id; + const opponents = game.players.filter(p => p.id !== owner); + const opponent = (opponents.find(p => p.faction === 'emperor') ?? opponents[0]).id; + const observer = opponents.find(p => p.id !== opponent)?.id; + assert.ok(observer, 'A genuine alliance and unallied Ecaz closing draw require three real seats.'); + const trainer = ecazSkillsModulesTrainer(game, owner).leader; + const strongholdKind = options.strongholdKind ?? 'arrakeen'; + const location = `${strongholdKind}:${territory(strongholdKind).sectors[0]}`; + const firstDial = ['tueks_sietch', 'habbanya_ridge_sietch'].includes(strongholdKind) ? 4 : 0; + reserveBoard(game, staging); + game = advanceEcazNexusSkillsModules(game, g => g.phase === 1 && ecazNexusSkillsModulesClean(g), actions, firstDial); + const afterFirstStorm = reloadEcazSkillsModules(game); + orderSpice(game, ['land', 'land'], staging); + game = advanceEcazNexusSkillsModules(game, g => g.phase === 8 && ecazNexusSkillsModulesClean(g), actions); + reserveBoard(game, staging); placeEcazNexusSkillsModulesForce(game, owner, location, 1, staging); + let beforeFirstMentat: Game | undefined, firstMentatStep: EcazNexusSkillsModulesStep | undefined; + for (let count = 0; game.turn === 1 && count < 200; count++) { + const before = reloadEcazSkillsModules(game), next = nextEcazNexusSkillsModulesStep(game); assert.ok(next); + game = stepEcazNexusSkillsModules(game, next, actions); + if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = next; } + } + assert.ok(beforeFirstMentat && firstMentatStep); + if (game.strongholdCards) assert.equal(game.strongholdCards.owners[strongholdKind], owner); + const afterFirstMentat = reloadEcazSkillsModules(game); + game = advanceEcazNexusSkillsModules(game, g => g.phase === 1 && ecazNexusSkillsModulesClean(g), actions); + orderSpice(game, ['worm', 'land', 'land'], staging); + game = advanceEcazNexusSkillsModules(game, g => g.nexus === true && !g.spiceWindow && !g.spiceResolution && ecazNexusSkillsModulesClean(g), actions); + const beforeAlliance = reloadEcazSkillsModules(game), allianceActions: EcazNexusSkillsModulesStep[] = [ + { actor: opponent, action: { type: 'alliance', target: observer } }, + { actor: observer, action: { type: 'alliance', target: opponent } }, + ]; + for (const next of allianceActions) game = stepEcazNexusSkillsModules(game, next, actions); + game = advanceEcazNexusSkillsModules(game, g => g.nexusCards?.phase?.stage === 'drawing' && ecazNexusSkillsModulesClean(g), actions); + const cards = game.nexusCards!.cards!, index = cards.deck.indexOf('ecaz'); assert.ok(index >= 0); + cards.deck.unshift(cards.deck.splice(index, 1)[0]); + staging.push('Order the unused original Ecaz Nexus singleton first immediately before its genuine closing draw; no Nexus hand is assigned.'); + const drawBefore = reloadEcazSkillsModules(game), drawStep: EcazNexusSkillsModulesStep = { actor: owner, + action: { type: 'nexusCardChoice', turn: game.turn, card: null, choice: 'draw', ownRedraws: 0 } }; + game = stepEcazNexusSkillsModules(game, drawStep, actions); + const closingDraw = { before: drawBefore, step: drawStep, after: reloadEcazSkillsModules(game) }; + game = advanceEcazNexusSkillsModules(game, g => g.phase === 5 && ecazNexusSkillsModulesClean(g), actions); + reserveBoard(game, staging); + for (const actor of [owner, opponent]) placeEcazNexusSkillsModulesForce(game, actor, location, 8, staging); + // Only controlled battle positions move native cards; fresh CLI deals and + // own-seat offers above remain authoritative and are exported unchanged. + for (const p of game.players) { game.deck.push(...p.hand); p.hand = []; } + staging.push('Before the real Movement closes, return original hands to the same Ecaz33 physical deck, then transfer only the requested native battle cards.'); + const worthless = holdEcazNexusSkillsModulesCard(game, owner, c => c.kind === 'worthless', staging); + const shield = options.defense ? holdEcazNexusSkillsModulesCard(game, owner, c => c.kind === 'shield', staging) : undefined; + const poison = options.poison ? holdEcazNexusSkillsModulesCard(game, opponent, c => c.kind === 'poison', staging) : undefined; + game = advanceEcazNexusSkillsModules(game, g => g.phase === 6 && ecazNexusSkillsModulesClean(g), actions); + const beforeCunning = reloadEcazSkillsModules(game), cunningStep = ecazNexusSkillsModulesCunningRequest(game, owner); + assert.equal(game.dukeVidal!.controller, null); + game = stepEcazNexusSkillsModules(game, cunningStep, actions); + const cunning = { before: beforeCunning, step: cunningStep, after: reloadEcazSkillsModules(game) }; + const beforeBattle = reloadEcazSkillsModules(game); + game = openEcazNexusSkillsModulesPosture(game, owner, opponent, strongholdKind, actions); + const posture = reloadEcazSkillsModules(game), band = options.band ?? (options.kind === 'suk' ? 'skilled' : 'normal'); + for (let count = 0; count < 200; count++) { + if (game.battle && ecazNexusSkillsModulesClean(game) && !game.battle.preparation && game.battle.preLeader?.closed !== false) break; + const next = nextEcazNexusSkillsModulesStep(game); assert.ok(next); + if (next.action.type === 'leaderSkillVisibility' && next.actor === owner) next.action.hide = band === 'skilled'; + game = stepEcazNexusSkillsModules(game, next, actions); + } + assert.ok(game.battle && ecazNexusSkillsModulesClean(game) && !game.battle.preparation && game.battle.preLeader?.closed !== false); + const enemy = ecazSkillsModulesPlayer(game, opponent), enemyTrainer = ecazSkillsModulesTrainer(game, opponent).leader; + const defender = enemy.leaders.filter(l => !l.dead && l.id !== enemyTrainer).sort((a, b) => + options.kind === 'suk' ? a.strength - b.strength : b.strength - a.strength)[0]; assert.ok(defender); + const ownLeader = options.kind === 'suk' ? band === 'skilled' ? trainer + : ecazSkillsModulesPlayer(game, owner).leaders.find(l => l.strength === 4 && l.id !== trainer)!.id : DUKE_VIDAL_ID; + const ownerDial = options.ownerDial ?? (options.kind === 'suk' ? 5 : 0); + const opponentDial = options.opponentDial ?? (options.kind === 'suk' ? 0 : game.battle.attacker === owner ? 2 : 1); + const plans: EcazNexusSkillsModulesStep[] = [ + { actor: owner, action: { type: 'battlePlan', leader: ownLeader, dial: ownerDial, + support: game.advanced ? ownerDial : 0, weapon: worthless.id, defense: shield?.id } }, + { actor: opponent, action: { type: 'battlePlan', leader: defender.id, dial: opponentDial, + support: game.advanced ? opponentDial : 0, weapon: poison?.id } }, + ]; + assertEcazNexusSkillsModulesCustody(game); + return { initial, offered, afterSetup, afterFirstStorm, beforeFirstMentat, firstMentatStep, afterFirstMentat, + beforeAlliance, allianceActions, closingDraw, beforeCunning, cunning, beforeBattle, posture, game, + owner, opponent, observer, trainer, territory: strongholdKind, location, plans, actions, staging }; +} diff --git a/tests/fixture-moritani-nexus-skills-modules.ts b/tests/fixture-moritani-nexus-skills-modules.ts new file mode 100644 index 00000000..175c2bad --- /dev/null +++ b/tests/fixture-moritani-nexus-skills-modules.ts @@ -0,0 +1,341 @@ +import assert from 'node:assert/strict'; +import { + applyAction, createGame, handLimit, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, viewGame, + type Action, type Game, +} from '../game/engine'; +import type { FactionId } from '../game/catalog'; +import type { Card } from '../game/cards'; +import { leaders, treacheryDeck } from '../game/cards'; +import { territory } from '../game/board'; +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 { TERROR_KINDS, type TerrorKind } from '../game/moritani-terror'; +import { nextNexusSkillsModulesBattleStep } from './fixture-nexus-skills-modules-battles'; +import { + moritaniSkillsModulesClean, moritaniSkillsModulesPlayer, moritaniSkillsModulesPolicy, + nextMoritaniSkillsModulesStep, revealMoritaniSkillsModulesPlans, stageMoritaniSkillsModulesBattle, +} from './fixture-moritani-skills-modules'; +import { holdMoritaniStrongholdsTraitor } from './fixture-moritani-strongholds'; +import type { StrongholdFactionsNativeStep } from './fixture-stronghold-factions'; + +export type MoritaniNexusSkillsModulesStep = StrongholdFactionsNativeStep; +export type MoritaniNexusSkillsModulesOptions = { + /** Original lobby or unassigned turn-one skill setup, never a converted save. */ + initial?: Game; + rules?: 'basic' | 'advanced'; + tech?: boolean; + strongholds?: boolean; + factions?: readonly FactionId[]; + seatIds?: readonly string[]; + /** Supplied offers are authoritative; an explicit request must actually be offered. */ + guildSkill?: LeaderSkillId; + moritaniSkill?: LeaderSkillId; + holder?: 'moritani' | 'guild'; + normalCall?: boolean; + stack?: boolean; +}; +export type MoritaniNexusSkillsModulesFixture = { + initial: Game; afterSetup: Game; afterFirstStorm: Game; + beforeFirstMentat: Game; firstMentatStep: MoritaniNexusSkillsModulesStep; afterFirstMentat: Game; + beforeAlliance: Game; allianceActions: MoritaniNexusSkillsModulesStep[]; + beforeClosingDraw: Game; closingDrawStep: MoritaniNexusSkillsModulesStep; afterClosingDraw: Game; + beforeBattle: Game; battleAction: MoritaniNexusSkillsModulesStep; + plans: Game; planActions: MoritaniNexusSkillsModulesStep[]; revealed: Game; pending: Game; game: Game; + moritani: string; opponent: string; partner: string; holder: string; + target: string; moritaniLeader: string; opponentLeader: string; + moritaniCards: string[]; opponentCards: string[]; karama: string; + actions: MoritaniNexusSkillsModulesStep[]; staging: string[]; +}; +export const moritaniNexusSkillsModulesPlayer = moritaniSkillsModulesPlayer; +export const moritaniNexusSkillsModulesPolicy = moritaniSkillsModulesPolicy; +export const moritaniNexusSkillsModulesClean = moritaniSkillsModulesClean; +const snapshot = (game: Game): Game => structuredClone(game); + +/** Only the original physical all14 shuffle is controlled, before Traitor deals. + * The remainder of the native initializer retains its original randomness. */ +function originalSkillShuffle(selected: LeaderSkillId[], initialize: () => T): T { + assert.equal(new Set(selected).size, selected.length); + const working: LeaderSkillId[] = LEADER_SKILL_CARDS.map(c => c.id); + const remainder = working.filter(c => !selected.includes(c)); + const desired = selected.flatMap(c => [c, remainder.shift()!]); desired.push(...remainder); + const rolls: number[] = []; + for (let i = working.length - 1; i > 0; i--) { + const j = working.indexOf(desired[i]); assert.ok(j >= 0 && j <= i); + rolls.push(Math.floor((j + 0.5) * 0x100000000 / (i + 1))); + [working[i], working[j]] = [working[j], working[i]]; + } + const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); + const native = crypto.getRandomValues.bind(crypto); let cursor = 0; + crypto.getRandomValues = (array: V): V => { + if (cursor < rolls.length && array instanceof Uint32Array && array.length === 1) array[0] = rolls[cursor++]; + else Reflect.apply(native, crypto, [array]); + return array; + }; + try { return initialize(); } + finally { + if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); + else Reflect.deleteProperty(crypto, 'getRandomValues'); + } +} +export function initializeMoritaniNexusSkillsModulesOffers(options: MoritaniNexusSkillsModulesOptions = {}): Game { + const advanced = options.rules ? options.rules === 'advanced' : options.initial?.advanced ?? true; + const tech = options.tech ?? !!options.initial?.techTokens; + const strongholds = options.strongholds ?? !!options.initial?.strongholdCards; + const factions: readonly FactionId[] = options.factions ?? ['moritani', 'guild', 'emperor']; + let game = options.initial ? snapshot(options.initial) + : createGame('MORITANINEXUSSKILLSMODULES', 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)); + assert.equal(game.advanced, advanced); assert.deepEqual(game.expansions, ['ecaz']); + assert.ok(!game.homeworlds && game.players.length >= (tech ? 3 : 2) && game.players.length <= 6); + assert.equal(game.players.filter(p => p.faction === 'moritani').length, 1); + assert.equal(game.players.filter(p => p.faction === 'guild').length, 1); + const allowed: FactionId[] = ['moritani', 'guild', 'emperor', 'atreides', 'fremen', 'beneGesserit', ...(advanced ? [] : ['harkonnen'] as FactionId[])]; + assert.ok(game.players.every(p => allowed.includes(p.faction))); assert.ok(advanced || !strongholds); + assert.ok(game.status === 'lobby' || game.status === 'setup' && game.turn === 1 && game.phase === 0 && + (game.setupStage === 'prediction' || game.setupStage === 'leaderSkills') && game.leaderSkills?.assignments.length === 0, + 'Only an original lobby or undealt skill-first setup may continue'); + if (game.status === 'setup') { + assert.equal(!!game.techTokens, tech); assert.equal(!!game.strongholdCards, strongholds); + assertMoritaniNexusSkillsModulesCustody(game); + while (game.setupStage === 'prediction') game = stepMoritaniNexusSkillsModules(game); + return game; + } + 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.nexusCards ??= { cards: null, phase: null }; + assert.deepEqual(game.nexusCards, { cards: null, phase: null }); assert.ok(!game.leaderSkills); + for (const p of game.players) if (!p.ready) game = applyAction(game, p.id, { type: 'ready' }); + const guildSkill = options.guildSkill ?? 'suk-graduate', moritaniSkill = options.moritaniSkill ?? 'warmaster'; + const passive: LeaderSkillId[] = ['sandmaster', 'killer-medic', 'prana-bindu-adept', 'master-of-assassins', 'swordmaster-of-ginaz', 'planetologist'] + .filter(c => c !== guildSkill && c !== moritaniSkill) as LeaderSkillId[]; + const selected = game.players.map(p => p.faction === 'guild' ? guildSkill : p.faction === 'moritani' ? moritaniSkill : passive.shift()!); + game = originalSkillShuffle(selected, () => initializeLeaderSkillsGameForAudit(game)); + while (game.setupStage === 'prediction') game = stepMoritaniNexusSkillsModules(game); + return game; +} +export function nextMoritaniNexusSkillsModulesStep(game: Game): MoritaniNexusSkillsModulesStep | null { + if (game.response || game.phaseOpening || game.pendingTreacheryDiscard) return nextMoritaniSkillsModulesStep(game); + const d = game.decision; + if (d?.kind === 'moritaniPlacement' || d?.kind === 'moritaniAssassinate' || d?.kind === 'moritaniSetup' || d?.kind === 'leaderSkillVisibility') + return nextMoritaniSkillsModulesStep(game); + if (d?.kind === 'moritaniTerror' && ['select', 'offer'].includes(game.pendingTerrorEntry!.stage)) + return { actor: d.player, action: { type: 'decision', decline: true } }; + return nextNexusSkillsModulesBattleStep(game); +} +export function stepMoritaniNexusSkillsModules(game: Game, next = nextMoritaniNexusSkillsModulesStep(game), actions?: MoritaniNexusSkillsModulesStep[]): Game { + assert.ok(next, 'An original human sealed Battle Plan is required'); actions?.push(structuredClone(next)); + return applyAction(game, next.actor, next.action); +} +export function advanceMoritaniNexusSkillsModules(game: Game, until: (state: Game) => boolean, actions?: MoritaniNexusSkillsModulesStep[]): Game { + for (let n = 0; n < 2000; n++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished'); game = stepMoritaniNexusSkillsModules(game, undefined, actions); + } + throw Error('Original Moritani Skills/Nexus continuation did not reach its requested boundary'); +} +export function completeMoritaniNexusSkillsModulesSetup(game: Game, options: MoritaniNexusSkillsModulesOptions = {}, actions?: MoritaniNexusSkillsModulesStep[]): Game { + for (let n = 0; game.status === 'setup' && n < 240; n++) { + if (game.setupStage !== 'leaderSkills') { game = stepMoritaniNexusSkillsModules(game, undefined, actions); continue; } + const actor = Object.keys(game.leaderSkills!.offers)[0], p = moritaniSkillsModulesPlayer(game, actor); + const own = viewGame(game, actor).leaderSkills!, offer = own.offer!; + const requested = p.faction === 'guild' ? options.guildSkill : p.faction === 'moritani' ? options.moritaniSkill : undefined; + const skill = requested ?? offer.cards.find(c => !own.unavailableSkills?.[c]); + assert.ok(skill && offer.cards.includes(skill) && !own.unavailableSkills?.[skill], 'Requested skill must be genuinely offered'); + const leader = p.faction === 'guild' && skill === 'suk-graduate' + ? own.eligibleLeaders.find(l => l.name === 'Master Bewt') : own.eligibleLeaders[0]; + assert.ok(leader); + game = stepMoritaniNexusSkillsModules(game, { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: leader.id } }, actions); + } + assert.equal(game.status, 'playing'); return game; +} +export function assertMoritaniNexusSkillsModulesCustody(game: Game): void { + validateLeaderSkills(game.leaderSkills!, game.players); 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.deepEqual(p.leaders.map(l => l.id).sort(), leaders(p.faction).map(l => l.id).sort()); + assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0), 20); + if (p.elites) { + assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((a, b) => a + b, 0), p.faction === 'emperor' ? 5 : 3); + for (const [key, count] of Object.entries(p.elites.forces)) assert.ok(count <= (p.forces[key] ?? 0)); + } + } + if (game.moritaniTerror) { + assert.deepEqual(game.moritaniTerror.tokens.map(t => t.kind).sort(), [...TERROR_KINDS].sort()); + assert.equal(new Set(game.moritaniTerror.tokens.map(t => t.id)).size, 6); + } +} +function reserveBoard(game: Game, staging: string[]) { + for (const p of game.players) { + p.reserves += Object.values(p.forces).reduce((a, b) => a + b, 0); p.forces = {}; + if (p.elites) { p.elites.reserves += Object.values(p.elites.forces).reduce((a, b) => a + b, 0); p.elites.forces = {}; } + if (p.advisors) p.advisors = {}; + } + staging.push('Conserved original board counters through their own reserves; wallets, phase, turn, skill effects and earned module custody unchanged.'); +} +function place(game: Game, actor: string, site: string, count: number, staging: string[]) { + const p = moritaniSkillsModulesPlayer(game, actor); assert.ok(p.reserves - (p.elites?.reserves ?? 0) >= count); + p.reserves -= count; const key = `${site}:${territory(site).sectors[0]}`; p.forces[key] = (p.forces[key] ?? 0) + count; + staging.push(`Conserved ${count} original ordinary ${actor} counters: own reserves → ${key}.`); +} +export function holdMoritaniNexusSkillsModulesCard(game: Game, actor: string, kind: Card['kind'] | 'karama', staging: string[] = [], excluded: string[] = []): string { + const p = moritaniSkillsModulesPlayer(game, actor); + const matches = (c: Card) => (c.kind === kind || c.effect === kind) && !excluded.includes(c.id); + const held = p.hand.find(matches); if (held) return held.id; + const index = game.deck.findIndex(matches); + const donor = index < 0 ? game.players.find(p => p.id !== actor && p.hand.some(matches)) : undefined; + assert.ok((index >= 0 || donor) && p.hand.length < handLimit(p)); + const card = index >= 0 ? game.deck.splice(index, 1)[0] : donor!.hand.splice(donor!.hand.findIndex(matches), 1)[0]; + p.hand.push(card); + staging.push(`Conserved original ecaz33 ${card.id}: ${donor?.id ?? 'unused deck'} → ${actor}'s hand; not a natural deal.`); return card.id; +} +function orderSpice(game: Game, kinds: ('land' | 'worm')[], staging: string[]) { + const front = kinds.map(kind => { + const index = game.spiceDeck.findIndex(c => kind === 'land' ? 'territory' in c : 'worm' in c); assert.ok(index >= 0); + return game.spiceDeck.splice(index, 1)[0]; + }); + game.spiceDeck.unshift(...front); staging.push(`Conserved original undealt Spice order: ${kinds.join(', ')}; original Discards unchanged.`); +} +export function resolveMoritaniNexusSkillsModulesBattle(game: Game, moritani: string, normalCall = false, actions?: MoritaniNexusSkillsModulesStep[]): Game { + for (let n = 0; n < 160; n++) { + if (game.decision?.kind === 'sukRescue' || game.decision?.kind === 'moritaniAssassinate' || + !game.battle && moritaniSkillsModulesClean(game) && !game.pendingTreacheryDiscard) return game; + const next = nextMoritaniNexusSkillsModulesStep(game); assert.ok(next); + if (next.action.type === 'traitorCall') next.action.call = normalCall && next.actor === moritani; + game = stepMoritaniNexusSkillsModules(game, next, actions); + } + throw Error('Original battle did not reach rescue, assassination or settled boundary'); +} +export type MoritaniNexusSkillsModulesSettlementBoundary = { + game: Game; + kind: 'losses' | 'rescue' | 'assassination' | 'cards' | 'tech' | 'settled'; +}; +/** Human physical casualty/rescue/assassination/cleanup/reward choices are never + * skipped to manufacture a later boundary. Apply that actual choice, then resume. */ +export function settleMoritaniNexusSkillsModulesBoundary(game: Game, moritani: string, normalCall = false, + actions?: MoritaniNexusSkillsModulesStep[]): MoritaniNexusSkillsModulesSettlementBoundary { + const choices: Partial['kind'], MoritaniNexusSkillsModulesSettlementBoundary['kind']>> = { + battleLosses: 'losses', sukRescue: 'rescue', moritaniAssassinate: 'assassination', battleCards: 'cards', techToken: 'tech', + }; + for (let n = 0; n < 160; n++) { + const kind = game.decision && choices[game.decision.kind]; + if (kind && !game.response && !game.phaseOpening) return { game, kind }; + if (!game.battle && moritaniSkillsModulesClean(game) && !game.pendingTreacheryDiscard) return { game, kind: 'settled' }; + const next = nextMoritaniNexusSkillsModulesStep(game); assert.ok(next); + if (next.action.type === 'traitorCall') next.action.call = normalCall && next.actor === moritani; + game = stepMoritaniNexusSkillsModules(game, next, actions); + } + throw Error('Original battle settlement did not reach its next physical human choice'); +} +export function finishMoritaniNexusSkillsModulesBattle(game: Game, actions?: MoritaniNexusSkillsModulesStep[]): Game { + return advanceMoritaniNexusSkillsModules(game, s => !s.battle && moritaniSkillsModulesClean(s) && !s.pendingTreacheryDiscard, actions); +} +export const revealMoritaniNexusSkillsModulesPlans = revealMoritaniSkillsModulesPlans; +export const stageMoritaniNexusSkillsModulesBattle = stageMoritaniSkillsModulesBattle; +export function moritaniNexusSkillsModulesToken(game: Game, kind: TerrorKind) { + const token = game.moritaniTerror!.tokens.find(t => t.kind === kind); assert.ok(token); return token; +} +export function moritaniNexusSkillsModulesRequest(game: Game, actor: string, kind: TerrorKind = 'robbery', site = 'red_chasm'): Action { + const offer = viewGame(game, actor).nexusMoritani; assert.ok(offer); + return { type: 'decision', token: moritaniNexusSkillsModulesToken(game, kind).id, territory: site, nexus: offer.event }; +} +/** Only pass the persisted native response queue, not a guessed policy actor. */ +export function allowMoritaniNexusSkillsModules(game: Game, actions?: MoritaniNexusSkillsModulesStep[]): Game { + for (let n = 0; (game.response || game.phaseOpening || game.pendingKarama) && n < 100; n++) + game = stepMoritaniNexusSkillsModules(game, undefined, actions); + assert.ok(!game.response && !game.phaseOpening && !game.pendingKarama); return game; +} +export function cancelMoritaniNexusSkillsModules(game: Game, actor: string, card: string, actions?: MoritaniNexusSkillsModulesStep[]): Game { + game = stepMoritaniNexusSkillsModules(game, { actor, action: { type: 'card', mode: 'cancel', card } }, actions); + return allowMoritaniNexusSkillsModules(game, actions); +} +export function rejectMoritaniNexusSkillsModulesAction(game: Game, actor: string, action: Action): void { + const before = snapshot(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before); +} + +/** Source: docs/NEXUS_MORITANI_RULES.md13–32 (publisher face and native rulebook). + * Genuine first Storm/END Mentat → worm/alliance/closing draw → original battle + * → actual second Mentat supply opportunity. Enhanced relocation, HMS and + * Grumman are deliberately NOT interpreted by this continuation. */ +export function createMoritaniNexusSkillsModulesFixture(options: MoritaniNexusSkillsModulesOptions = {}): MoritaniNexusSkillsModulesFixture { + const actions: MoritaniNexusSkillsModulesStep[] = [], staging: string[] = []; + let game = initializeMoritaniNexusSkillsModulesOffers(options); const initial = snapshot(game); + game = completeMoritaniNexusSkillsModulesSetup(game, options, actions); const afterSetup = snapshot(game); + const moritani = game.players.find(p => p.faction === 'moritani')!.id; + const opponent = game.players.find(p => p.faction === 'guild')!.id; + const partner = game.players.find(p => p.id !== moritani && p.id !== opponent)?.id; + assert.ok(partner, 'A real unallied closing Nexus draw requires two other original alliance seats; two-seat setup is supported separately.'); + while (game.phase === 0) { + const next = nextMoritaniNexusSkillsModulesStep(game); assert.ok(next); + if (next.action.type === 'stormDial') next.action.amount = 4; + game = stepMoritaniNexusSkillsModules(game, next, actions); + } + game = advanceMoritaniNexusSkillsModules(game, s => s.phase === 1 && moritaniSkillsModulesClean(s), actions); + const afterFirstStorm = snapshot(game); orderSpice(game, ['land', 'land'], staging); + game = advanceMoritaniNexusSkillsModules(game, s => s.decision?.kind === 'moritaniPlacement', actions); + if (options.stack) { + game = stepMoritaniNexusSkillsModules(game, { actor: moritani, action: { type: 'decision', token: moritaniNexusSkillsModulesToken(game, 'sabotage').id, territory: 'arrakeen' } }, actions); + game = allowMoritaniNexusSkillsModules(game, actions); + } else game = stepMoritaniNexusSkillsModules(game, undefined, actions); + reserveBoard(game, staging); + const holder = options.holder === 'moritani' ? moritani : opponent; + place(game, holder, 'tueks_sietch', 1, staging); place(game, holder === moritani ? opponent : moritani, 'carthag', 1, staging); + let beforeFirstMentat: Game | undefined, firstMentatStep: MoritaniNexusSkillsModulesStep | undefined; + while (game.turn === 1) { + const before = snapshot(game), next = nextMoritaniNexusSkillsModulesStep(game); assert.ok(next); + game = stepMoritaniNexusSkillsModules(game, next, actions); + if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = next; } + } + assert.ok(beforeFirstMentat && firstMentatStep); const afterFirstMentat = snapshot(game); + game = advanceMoritaniNexusSkillsModules(game, s => s.phase === 1 && moritaniSkillsModulesClean(s), actions); + orderSpice(game, ['worm', 'land', 'land'], staging); + game = advanceMoritaniNexusSkillsModules(game, s => s.nexus === true && !s.spiceWindow && !s.spiceResolution && moritaniSkillsModulesClean(s), actions); + const beforeAlliance = snapshot(game), allianceActions: MoritaniNexusSkillsModulesStep[] = [ + { actor: opponent, action: { type: 'alliance', target: partner } }, + { actor: partner, action: { type: 'alliance', target: opponent } }, + ]; + for (const next of allianceActions) game = stepMoritaniNexusSkillsModules(game, next, actions); + game = advanceMoritaniNexusSkillsModules(game, s => s.nexusCards?.phase?.stage === 'drawing' && moritaniSkillsModulesClean(s), actions); + const cards = game.nexusCards!.cards!, index = cards.deck.indexOf('moritani'); assert.ok(index >= 0); + cards.deck.unshift(cards.deck.splice(index, 1)[0]); staging.push('Conserved undealt original Moritani Nexus singleton ordered before its real unallied closing draw.'); + const beforeClosingDraw = snapshot(game), closingDrawStep: MoritaniNexusSkillsModulesStep = { + actor: moritani, action: { type: 'nexusCardChoice', turn: game.turn, card: cards.hands[moritani], choice: 'draw', ownRedraws: 0 }, + }; + game = stepMoritaniNexusSkillsModules(game, closingDrawStep, actions); const afterClosingDraw = snapshot(game); + game = advanceMoritaniNexusSkillsModules(game, s => s.phase === 5 && moritaniSkillsModulesClean(s), actions); + stageMoritaniSkillsModulesBattle(game, moritani, opponent, staging); + const trained = new Set(game.leaderSkills!.assignments.map(a => a.leader)); + const ownLeader = moritaniSkillsModulesPlayer(game, moritani).leaders.filter(l => !l.dead && !trained.has(l.id)).sort((a, b) => a.strength - b.strength)[0]; + const enemyLeader = moritaniSkillsModulesPlayer(game, opponent).leaders.filter(l => !l.dead && !trained.has(l.id)).sort((a, b) => b.strength - a.strength)[0]; + const target = game.leaderSkills!.assignments.find(a => a.owner === opponent)!.leader; assert.ok(ownLeader && enemyLeader && enemyLeader.id !== target); + holdMoritaniStrongholdsTraitor(game, moritani, options.normalCall ? enemyLeader.id : target, staging); + const moritaniCards = [holdMoritaniNexusSkillsModulesCard(game, moritani, 'worthless', staging)]; + moritaniCards.push(holdMoritaniNexusSkillsModulesCard(game, moritani, 'worthless', staging, moritaniCards)); + const opponentCards = [holdMoritaniNexusSkillsModulesCard(game, opponent, 'worthless', staging)]; + game = advanceMoritaniNexusSkillsModules(game, s => s.phase === 6 && moritaniSkillsModulesClean(s), actions); + const beforeBattle = snapshot(game), actor = game.active!; assert.ok([moritani, opponent].includes(actor)); + const battleAction: MoritaniNexusSkillsModulesStep = { actor, action: { type: 'chooseBattle', territory: 'tueks_sietch', target: actor === moritani ? opponent : moritani } }; + game = stepMoritaniNexusSkillsModules(game, battleAction, actions); + game = advanceMoritaniNexusSkillsModules(game, s => !!s.battle && !s.battle.revealed && !nextMoritaniNexusSkillsModulesStep(s), actions); + const planActions: MoritaniNexusSkillsModulesStep[] = [ + { actor: moritani, action: { type: 'battlePlan', leader: ownLeader.id, dial: 1, ...(game.advanced ? { support: 1 } : {}), weapon: moritaniCards[0], defense: moritaniCards[1] } }, + { actor: opponent, action: { type: 'battlePlan', leader: enemyLeader.id, dial: 3, ...(game.advanced ? { support: 3 } : {}), weapon: opponentCards[0] } }, + ]; + const plans = snapshot(game), revealed = revealMoritaniSkillsModulesPlans(game, planActions, actions); + const pending = resolveMoritaniNexusSkillsModulesBattle(snapshot(revealed), moritani, options.normalCall, actions); + game = finishMoritaniNexusSkillsModulesBattle(snapshot(pending), actions); + game = advanceMoritaniNexusSkillsModules(game, s => s.decision?.kind === 'moritaniPlacement', actions); + const karama = holdMoritaniNexusSkillsModulesCard(game, opponent, 'karama', staging); + assertMoritaniNexusSkillsModulesCustody(game); + return { initial, afterSetup, afterFirstStorm, beforeFirstMentat, firstMentatStep, afterFirstMentat, + beforeAlliance, allianceActions, beforeClosingDraw, closingDrawStep, afterClosingDraw, beforeBattle, + battleAction, plans, planActions, revealed, pending, game, moritani, opponent, partner, holder, + target, moritaniLeader: ownLeader.id, opponentLeader: enemyLeader.id, moritaniCards, opponentCards, + karama, actions, staging }; +} diff --git a/tests/moritani-nexus-skills-modules-runtime.test.ts b/tests/moritani-nexus-skills-modules-runtime.test.ts new file mode 100644 index 00000000..221bd76d --- /dev/null +++ b/tests/moritani-nexus-skills-modules-runtime.test.ts @@ -0,0 +1,360 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, createGame, joinGame, newPlayer, normalizeAutomaticGame, viewGame, type Game } from '../game/engine'; +import type { FactionId } from '../game/catalog'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { ownedTech } from '../game/tech-tokens'; +import { strongholdControllers } from '../game/stronghold-cards'; +import { territory } from '../game/board'; +import { quoteNexusSkillsModulesBattle } from './fixture-nexus-skills-modules-battles'; +import { quoteStrongholdFactionsBattle } from './fixture-stronghold-factions'; +import { assassinationPhysical } from './moritani-assassinate-fixture'; +import { holdMoritaniStrongholdsTraitor } from './fixture-moritani-strongholds'; +import { + advanceMoritaniNexusSkillsModules as advance, allowMoritaniNexusSkillsModules as allow, + assertMoritaniNexusSkillsModulesCustody as custody, cancelMoritaniNexusSkillsModules as cancel, + completeMoritaniNexusSkillsModulesSetup, createMoritaniNexusSkillsModulesFixture as fixture, + finishMoritaniNexusSkillsModulesBattle as finishBattle, initializeMoritaniNexusSkillsModulesOffers, + moritaniNexusSkillsModulesClean as clean, moritaniNexusSkillsModulesPlayer as player, + moritaniNexusSkillsModulesPolicy as policy, moritaniNexusSkillsModulesRequest as request, + moritaniNexusSkillsModulesToken as token, nextMoritaniNexusSkillsModulesStep, + rejectMoritaniNexusSkillsModulesAction as reject, resolveMoritaniNexusSkillsModulesBattle as resolve, + revealMoritaniNexusSkillsModulesPlans as reveal, stageMoritaniNexusSkillsModulesBattle as stage, + settleMoritaniNexusSkillsModulesBoundary, + type MoritaniNexusSkillsModulesOptions, +} from './fixture-moritani-nexus-skills-modules'; + +const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game; +function physicalPlayers(game: Game) { + return game.players.map(p => ({ id: p.id, spice: p.spice, forces: p.forces, reserves: p.reserves, tanks: p.tanks, elites: p.elites })); +} +function assassinate(game: Game, card: string | null): Game { + const d = game.decision; + if (d?.kind !== 'moritaniAssassinate') throw Error('An original post-loss assassination choice is required'); + return applyAction(game, d.player, { type: 'decision', event: d.event, ...(card === null ? { decline: true } : { card }) }); +} +const bands: MoritaniNexusSkillsModulesOptions[] = [ + { rules: 'basic', tech: false, strongholds: false }, + { rules: 'advanced', tech: false, strongholds: false }, + { rules: 'basic', tech: true, strongholds: false }, + { rules: 'advanced', tech: true, strongholds: false }, + { rules: 'advanced', tech: false, strongholds: true }, + { rules: 'advanced', tech: true, strongholds: true }, +]; + +void test('original standalone E3 skill-first admission conserves ecaz33/all14/all12 across no-module and selected-module two-to-six-seat bands', () => { + const factions: FactionId[] = ['moritani', 'guild', 'emperor', 'atreides', 'fremen', 'beneGesserit']; + for (const options of bands) for (let count = options.tech ? 3 : 2; count <= 6; count++) { + const lobby = createGame(`MORINEXUSAUTH${count}`, newPlayer('authenticated-m', 'Original Moritani', 'moritani'), options.rules === 'advanced', ['ecaz']); + for (let i = 1; i < count; i++) joinGame(lobby, newPlayer(`authenticated-${i}`, `Original ${factions[i]}`, factions[i])); + const before = structuredClone(lobby), offered = initializeMoritaniNexusSkillsModulesOffers({ ...options, initial: lobby }); + assert.deepEqual(lobby, before); + assert.deepEqual(offered.players.map(p => ({ id: p.id, name: p.name, faction: p.faction })), before.players.map(p => ({ id: p.id, name: p.name, faction: p.faction }))); + assert.equal(offered.setupStage, 'leaderSkills'); assert.ok(offered.players.every(p => p.traitors.length === 0)); + for (const p of offered.players) assert.equal(viewGame(offered, p.id).leaderSkills!.offer!.cards.length, 2); + assert.equal(!!offered.moritaniAssassinate, options.rules === 'advanced'); + assert.deepEqual(initializeMoritaniNexusSkillsModulesOffers({ ...options, initial: reload(offered) }), offered, 'Undealt original CLI continuation must not replace actual offers'); + const guild = offered.players.find(p => p.faction === 'guild')!.id; + const unavailable = viewGame(offered, guild).leaderSkills!.offer!.cards.includes('sandmaster') ? 'swordmaster-of-ginaz' : 'sandmaster'; + assert.ok(!viewGame(offered, guild).leaderSkills!.offer!.cards.includes(unavailable)); + assert.throws(() => completeMoritaniNexusSkillsModulesSetup(reload(offered), { guildSkill: unavailable })); + const setup = completeMoritaniNexusSkillsModulesSetup(offered), moritani = player(setup, 'authenticated-m'); + assert.equal(moritani.reserves, 14); assert.equal(Object.values(moritani.forces).reduce((a, b) => a + b, 0), 6); + const startingSite = Object.keys(moritani.forces)[0].split(':')[0]; + assert.ok(setup.players.filter(p => p.id !== moritani.id).every(p => !Object.entries(p.forces).some(([key, n]) => n > 0 && key.startsWith(`${startingSite}:`)))); + assert.equal(setup.leaderSkills!.assignments.find(a => a.owner === guild)!.skill, 'suk-graduate'); + assert.equal(!!setup.techTokens, !!options.tech); assert.equal(!!setup.strongholdCards, !!options.strongholds); + if (setup.strongholdCards) assert.ok(Object.values(setup.strongholdCards.owners).every(owner => owner === null)); + custody(setup); + if (count === 2) assert.throws(() => fixture({ ...options, initial: reload(lobby) }), /two other original alliance seats/); + } +}); + +void test('Basic original Harkonnen remains native while Advanced excludes capture bounds and paired/foreign E3 rosters', () => { + const basic = initializeMoritaniNexusSkillsModulesOffers({ rules: 'basic', factions: ['moritani', 'guild', 'harkonnen'] }); + const setup = completeMoritaniNexusSkillsModulesSetup(basic); + assert.equal(player(setup, 'harkonnen').traitors.length, 4); assert.equal(setup.moritaniAssassinate, undefined); custody(setup); + for (const faction of ['harkonnen', 'ecaz', 'ixians', 'richese', 'tleilaxu', 'choam'] as FactionId[]) + assert.throws(() => initializeMoritaniNexusSkillsModulesOffers({ rules: 'advanced', factions: ['moritani', 'guild', faction] })); + assert.throws(() => initializeMoritaniNexusSkillsModulesOffers({ rules: 'basic', strongholds: true })); + assert.throws(() => initializeMoritaniNexusSkillsModulesOffers({ tech: true, factions: ['moritani', 'guild'] })); + assert.throws(() => initializeMoritaniNexusSkillsModulesOffers({ initial: setup }), /original lobby or undealt/); +}); + +void test('Tech custody comes from the first real Storm, Strongholds from END Mentat, and Cunning from the genuine worm/alliance closing draw', () => { + const f = fixture({ tech: true, strongholds: true }); + assert.ok(Object.values(f.initial.techTokens!).every(t => t.owner === null)); + for (const p of f.afterFirstStorm.players) assert.equal(ownedTech(f.afterFirstStorm.techTokens, p.id).length, 1); + assert.ok(Object.values(f.initial.strongholdCards!.owners).every(owner => owner === null)); + const claimed = applyAction(reload(f.beforeFirstMentat), f.firstMentatStep.actor, f.firstMentatStep.action); + assert.equal(claimed.turn, 2); + assert.deepEqual(claimed.strongholdCards!.owners, strongholdControllers(f.beforeFirstMentat.players, false)); + assert.deepEqual(claimed.strongholdCards, f.afterFirstMentat.strongholdCards); + let allied = reload(f.beforeAlliance); + for (const next of f.allianceActions) allied = applyAction(allied, next.actor, next.action); + assert.equal(player(allied, f.opponent).ally, f.partner); assert.equal(player(allied, f.partner).ally, f.opponent); + assert.equal(player(allied, f.moritani).ally, null); + assert.equal(f.beforeClosingDraw.nexusCards!.phase!.stage, 'drawing'); + assert.ok(f.beforeClosingDraw.nexusCards!.phase!.eligible.includes(f.moritani)); + assert.equal(f.beforeClosingDraw.nexusCards!.cards!.hands[f.moritani], null); + const drawn = applyAction(reload(f.beforeClosingDraw), f.closingDrawStep.actor, f.closingDrawStep.action); + assert.equal(drawn.nexusCards!.cards!.hands[f.moritani], 'moritani'); + assert.deepEqual(drawn.nexusCards, f.afterClosingDraw.nexusCards); + reject(drawn, f.moritani, f.closingDrawStep.action); + for (const g of [f.initial, f.afterSetup, f.afterFirstStorm, f.afterFirstMentat, drawn, f.pending, f.game]) custody(g); +}); + +void test('genuine Cunning declares only supply into static Arrakis or an existing stack; allowance commits once and cancellation spends the card and attempt without moving a token', () => { + for (const rules of ['basic', 'advanced'] as const) for (const site of ['red_chasm', 'sihaya_ridge', 'polar_sink', 'arrakeen']) { + const f = fixture({ rules, stack: true }), original = reload(f.game), action = request(original, f.moritani, 'robbery', site); + reject(original, f.opponent, action); reject(original, f.moritani, { ...action, nexus: 'stale' }); + reject(original, f.moritani, { ...action, token: token(original, 'sabotage').id }); + for (const bad of ['grumman', 'hidden_mobile_stronghold', 'unknown']) reject(original, f.moritani, { ...action, territory: bad }); + const nativeAttempt = { type: 'decision', token: token(original, 'robbery').id, territory: site }; + reject(original, f.moritani, nativeAttempt); + const declared = applyAction(reload(original), f.moritani, action); + assert.equal(declared.response?.kind, 'moritaniPlacement'); assert.deepEqual(declared.moritaniTerror, original.moritaniTerror); + assert.equal(declared.nexusCards!.cards!.hands[f.moritani], null); + assert.equal(declared.nexusCards!.cards!.discard.filter(c => c === 'moritani').length, 1); + const allowed = allow(reload(declared)); + assert.equal(token(allowed, 'robbery').status, 'placed'); assert.equal(token(allowed, 'robbery').location, site); + assert.equal(allowed.moritaniTerror!.placementTurn, 2); assert.equal(allowed.nexusMoritaniLast!.stage, 'complete'); + assert.deepEqual(allowed.moritaniTerror!.tokens.filter(t => t.kind !== 'robbery'), original.moritaniTerror!.tokens.filter(t => t.kind !== 'robbery')); + assert.deepEqual(physicalPlayers(allowed), physicalPlayers(original)); + assert.deepEqual(allowed.leaderSkills, original.leaderSkills); assert.deepEqual(allowed.techTokens, original.techTokens); assert.deepEqual(allowed.strongholdCards, original.strongholdCards); + if (site === 'arrakeen') assert.equal(allowed.moritaniTerror!.tokens.filter(t => t.location === site).length, 2); + const canceled = cancel(reload(declared), f.opponent, f.karama); + assert.equal(canceled.nexusMoritaniLast!.stage, 'canceled'); assert.equal(canceled.moritaniTerror!.placementTurn, 2); + assert.deepEqual(canceled.moritaniTerror!.tokens, original.moritaniTerror!.tokens); + assert.equal(canceled.nexusCards!.cards!.hands[f.moritani], null); + assert.equal(canceled.discard.filter(c => c.id === f.karama).length, 1); + assert.deepEqual(physicalPlayers(canceled), physicalPlayers(original)); + for (const settled of [allowed, canceled]) { + reject(settled, f.moritani, action); reject(settled, f.moritani, { type: 'decision', token: token(settled, 'extortion').id, territory: 'carthag' }); + assert.deepEqual(normalizeAutomaticGame(reload(settled)), settled); custody(settled); + for (const p of settled.players.filter(p => p.id !== f.moritani)) { + assert.equal(viewGame(settled, p.id).nexusMoritani, null); + assert.equal('nexusMoritaniHistory' in viewGame(settled, p.id), false); + assert.ok(viewGame(settled, p.id).moritaniTerror!.tokens.filter(t => t.status === 'placed').every(t => !('kind' in t))); + } + } + } +}); + +void test('ordinary later relocation remains native-only and does not reuse the historical expanded Cunning placement', () => { + const f = fixture({ stack: true }); + let game = allow(applyAction(reload(f.game), f.moritani, request(f.game, f.moritani))); + const placedId = token(game, 'robbery').id; + game = advance(game, s => s.turn === 3 && s.decision?.kind === 'moritaniPlacement'); + assert.equal(token(game, 'robbery').location, 'red_chasm'); + assert.equal(game.nexusCards!.cards!.hands[f.moritani], null); + reject(game, f.moritani, { type: 'decision', token: placedId, territory: 'sihaya_ridge' }); + reject(game, f.moritani, { type: 'decision', token: placedId, territory: 'arrakeen' }); + reject(game, f.moritani, { type: 'decision', token: placedId, territory: 'hidden_mobile_stronghold' }); + const before = reload(game); + game = allow(applyAction(game, f.moritani, { type: 'decision', token: placedId, territory: 'carthag' })); + assert.equal(token(game, 'robbery').id, placedId); assert.equal(token(game, 'robbery').location, 'carthag'); + assert.equal(game.moritaniTerror!.placementTurn, 3); assert.equal(game.nexusMoritaniLocations?.[placedId], undefined); + assert.deepEqual(game.nexusMoritaniHistory, before.nexusMoritaniHistory); + assert.deepEqual(physicalPlayers(game), physicalPlayers(before)); custody(game); +}); + +void test('normal held Suk physical rescue precedes unused-disc assassination, cleanup and original winner Tech; printed bounty and one private actual Mentat replacement do not replay settlement', () => { + const f = fixture({ tech: true, strongholds: true, holder: 'moritani' }), pending = f.pending; + assert.equal(pending.lastBattleContext!.result, 'normal'); assert.equal(pending.lastBattleContext!.winner, f.opponent); + assert.equal(pending.lastBattleContext!.sukRescue!.completed, true); assert.equal(pending.decision!.kind, 'moritaniAssassinate'); + assert.equal(player(pending, f.opponent).tanks, player(f.revealed, f.opponent).tanks + 2); + assert.equal(player(pending, f.opponent).reserves, player(f.revealed, f.opponent).reserves + 1); + assert.equal(player(pending, f.moritani).tanks, player(f.revealed, f.moritani).tanks + 6); + const target = player(pending, f.opponent).leaders.find(l => l.id === f.target)!; + assert.equal(target.name, 'Master Bewt'); assert.equal(target.strength, 3); assert.equal(target.dead, false); assert.notEqual(target.id, f.opponentLeader); + const quoted = quoteStrongholdFactionsBattle(f.revealed); + assert.deepEqual(quoted.strongholdIncome, [{ player: f.moritani, amount: 4 }]); + const skilledQuote = quoteNexusSkillsModulesBattle(f.revealed); + const heldBonus = f.revealed.battle!.attacker === f.moritani ? skilledQuote.leaderSkillBonuses.attacker : skilledQuote.leaderSkillBonuses.defender; + assert.equal(heldBonus.bonus, 1, 'The unused face-up Warmaster still adds its printed +1 to the other disc using Worthless.'); + assert.ok(heldBonus.applied.some(b => b.skill === 'warmaster' && b.mode === 'normal' && b.amount === 1)); + assert.equal(player(pending, f.moritani).spice, player(f.revealed, f.moritani).spice - 1 + 4); + assert.equal(player(pending, f.opponent).spice, player(f.revealed, f.opponent).spice - 3); + const d = pending.decision; + if (d?.kind !== 'moritaniAssassinate') throw Error('Expected loss choice'); + assert.equal(viewGame(pending, f.moritani).moritaniAssassinate!.pending!.cards.find(c => c.card === target.id)!.bounty, 3); + const action = { type: 'decision', event: d.event, card: target.id }; + reject(pending, f.opponent, action); reject(pending, f.moritani, { ...action, event: 'stale' }); + reject(pending, f.moritani, { ...action, card: f.opponentLeader }); + const inventory = assassinationPhysical(pending), reserve = [...pending.traitorReserve!], traitors = [...player(pending, f.moritani).traitors]; + const retained = pending.leaderSkills!.assignments.filter(a => a.leader !== target.id); + let game = assassinate(reload(pending), target.id); + assert.equal(player(game, f.opponent).leaders.find(l => l.id === target.id)!.dead, true); + assert.equal(player(game, f.opponent).leaders.find(l => l.id === target.id)!.deaths, target.deaths + 1); + assert.deepEqual(game.leaderSkills!.assignments, retained); assert.equal(game.leaderSkills!.deck.filter(c => c === 'suk-graduate').length, 1); + assert.equal(player(game, f.moritani).spice, player(pending, f.moritani).spice + 3); + assert.equal(player(game, f.opponent).spice, player(pending, f.opponent).spice); + assert.deepEqual(game.players.map(p => ({ id: p.id, forces: p.forces, reserves: p.reserves, tanks: p.tanks })), pending.players.map(p => ({ id: p.id, forces: p.forces, reserves: p.reserves, tanks: p.tanks }))); + assert.deepEqual(game.strongholdCards, pending.strongholdCards); assert.deepEqual(game.techTokens, pending.techTokens); + assert.equal(game.nexusCards!.cards!.hands[f.moritani], 'moritani'); + assert.equal(game.decision?.kind, 'battleCards'); assert.equal(game.decision?.player, f.opponent); + const cleanupBoundary = settleMoritaniNexusSkillsModulesBoundary(game, f.moritani); + assert.equal(cleanupBoundary.kind, 'cards'); + assert.deepEqual(cleanupBoundary.game.techTokens, pending.techTokens, 'A human card decision must precede winner Tech custody'); + game = applyAction(game, f.opponent, { type: 'decision', discard: f.opponentCards }); + game = finishBattle(game); + for (const card of [...f.moritaniCards, ...f.opponentCards]) assert.equal(game.discard.filter(c => c.id === card).length, 1); + const captured = ownedTech(pending.techTokens, f.moritani)[0]; assert.ok(captured); + assert.equal(game.techTokens![captured].owner, f.opponent); + for (const held of ownedTech(pending.techTokens, f.opponent)) assert.equal(game.techTokens![held].owner, f.opponent); + assert.equal(game.strongholdCards!.owners.tueks_sietch, f.moritani, 'Battle control cannot preempt END Mentat card custody'); + game = advance(game, s => s.decision?.kind === 'moritaniPlacement'); + const receipt = game.moritaniAssassinate!.opportunities.at(-1)!; + assert.equal(receipt.stage, 'replaced'); assert.equal(receipt.replacement, reserve[0]); + assert.deepEqual(game.traitorReserve, reserve.slice(1)); + assert.deepEqual(player(game, f.moritani).traitors, [...traitors.filter(c => c !== target.id), reserve[0]]); + assert.deepEqual(assassinationPhysical(game), inventory); + assert.equal('replacement' in viewGame(game, f.opponent).moritaniAssassinate!.history.at(-1)!, false); + assert.equal('traitors' in viewGame(game, f.opponent).players.find(p => p.id === f.moritani)!, false); + assert.equal(game.leaderSkills!.offers[f.opponent], undefined, 'Original replacement Traitor is not foreign replacement training'); + reject(game, f.moritani, action); custody(game); + const placed = allow(applyAction(game, f.moritani, request(game, f.moritani))); + game = advance(placed, s => s.turn === 3 && s.phase === 4 && clean(s)); + assert.equal(game.traitorReserve!.length, reserve.length - 1); + assert.equal(game.moritaniAssassinate!.opportunities.length, 1); + reject(game, f.opponent, { type: 'reviveLeader', leader: target.id }); custody(game); +}); + +void test('selected-disc-only empty menu can decline without removing Suk or drawing a replacement, while every component band preserves the original loss aftermath', () => { + for (const options of bands) { + const f = fixture(options), pending = f.pending; + assert.equal(pending.lastBattleContext!.winner, f.opponent); assert.equal(pending.lastBattleContext!.result, 'normal'); + assert.equal(pending.lastBattleContext!.sukRescue!.completed, true); + assert.equal(player(pending, f.opponent).tanks, 2); assert.equal(player(pending, f.moritani).tanks, 6); + assert.equal(!!pending.techTokens, !!options.tech); assert.equal(!!pending.strongholdCards, !!options.strongholds); + if (options.rules === 'basic') { + assert.equal(pending.moritaniAssassinate, undefined); + assert.equal(player(pending, f.opponent).spice, player(f.revealed, f.opponent).spice); + assert.equal(player(pending, f.moritani).spice, player(f.revealed, f.moritani).spice); + reject(pending, f.moritani, { type: 'decision', event: 'not-earned', card: f.target }); + assert.ok(pending.leaderSkills!.assignments.some(a => a.leader === f.target && a.skill === 'suk-graduate')); + if (options.tech) { + const lost = ownedTech(f.revealed.techTokens, f.moritani)[0]; assert.ok(lost); assert.equal(pending.techTokens![lost].owner, f.opponent); + } + } else { + assert.equal(pending.decision!.kind, 'moritaniAssassinate'); + let game = assassinate(reload(pending), f.target); game = finishBattle(game); + game = advance(game, s => s.decision?.kind === 'moritaniPlacement'); + assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.stage, 'replaced'); + assert.equal(game.leaderSkills!.deck.filter(c => c === 'suk-graduate').length, 1); + if (options.tech) { + const lost = ownedTech(pending.techTokens, f.moritani)[0]; assert.ok(lost); assert.equal(game.techTokens![lost].owner, f.opponent); + } + custody(game); + } + custody(pending); + } + const f = fixture(); let empty = reload(f.plans); + holdMoritaniStrongholdsTraitor(empty, f.moritani, f.opponentLeader); + empty = resolve(reveal(empty, f.planActions), f.moritani); + assert.deepEqual(viewGame(empty, f.moritani).moritaniAssassinate!.pending!.cards, []); + const reserve = [...empty.traitorReserve!], skills = structuredClone(empty.leaderSkills); + const d = empty.decision; + if (d?.kind !== 'moritaniAssassinate') throw Error('Expected empty unused-leader menu'); + reject(empty, f.moritani, { type: 'decision', event: d.event, card: f.opponentLeader }); + const declined = advance(finishBattle(assassinate(empty, null)), s => s.decision?.kind === 'moritaniPlacement'); + assert.equal(declined.moritaniAssassinate!.opportunities.at(-1)!.stage, 'declined'); + assert.deepEqual(declined.traitorReserve, reserve); assert.deepEqual(declined.leaderSkills, skills); custody(declined); +}); + +void test('normal Traitor declaration forfeits Advanced assassination for the rest of the original game, including a later real normal loss', () => { + const f = fixture({ tech: true, strongholds: true, normalCall: true }); + assert.equal(f.pending.lastBattleContext!.result, 'traitor'); assert.equal(f.pending.lastBattleContext!.winner, f.moritani); + assert.equal(f.pending.moritaniAssassinate!.normalTraitorCall, true); + assert.equal(f.pending.moritaniAssassinateCallEvents!.length, 1); assert.deepEqual(f.pending.moritaniAssassinate!.opportunities, []); + let game = advance(reload(f.game), s => s.turn === 3 && s.phase === 5 && clean(s)); + stage(game, f.moritani, f.opponent); holdMoritaniStrongholdsTraitor(game, f.moritani, f.target); + const assigned = new Set(game.leaderSkills!.assignments.map(a => a.leader)); + const winner = player(game, f.opponent).leaders.filter(l => !l.dead && !assigned.has(l.id)).sort((a, b) => b.strength - a.strength)[0]; + const loser = player(game, f.moritani).leaders.filter(l => !l.dead && !assigned.has(l.id)).sort((a, b) => a.strength - b.strength)[0]; + assert.ok(winner && loser); + game = advance(game, s => s.phase === 6 && clean(s)); + const actor = game.active!; + game = applyAction(game, actor, { type: 'chooseBattle', territory: 'tueks_sietch', target: actor === f.moritani ? f.opponent : f.moritani }); + game = advance(game, s => !!s.battle && !s.battle.revealed && !nextMoritaniNexusSkillsModulesStep(s)); + game = reveal(game, [ + { actor: f.moritani, action: { type: 'battlePlan', leader: loser.id, dial: 0, support: 0 } }, + { actor: f.opponent, action: { type: 'battlePlan', leader: winner.id, dial: 3, support: 3 } }, + ]); + game = finishBattle(resolve(game, f.moritani)); + assert.equal(game.lastBattleContext!.result, 'normal'); assert.equal(game.lastBattleContext!.winner, f.opponent); + assert.equal(game.moritaniAssassinate!.normalTraitorCall, true); assert.equal(game.moritaniAssassinateCallEvents!.length, 1); + assert.deepEqual(game.moritaniAssassinate!.opportunities, []); + assert.equal(player(game, f.opponent).leaders.find(l => l.id === f.target)!.dead, false); custody(game); +}); + +void test('Basic Cunning placement leads to genuine later legal Terror on a real shipment without granting Advanced assassination', () => { + const f = fixture({ rules: 'basic' }); + let game = allow(applyAction(reload(f.game), f.moritani, request(f.game, f.moritani, 'robbery', 'red_chasm'))); + game = advance(game, s => s.turn === 3 && s.phase === 5 && s.active === f.opponent && clean(s)); + const sector = territorySector(game, 'red_chasm'); + game = applyAction(game, f.opponent, { type: 'ship', territory: 'red_chasm', sector, amount: 3 }); + game = allow(game); + assert.equal(game.pendingTerrorEntry!.stage, 'offer'); assert.equal(viewGame(game, f.moritani).terrorEntry!.kind, 'robbery'); + assert.equal(viewGame(game, f.opponent).terrorEntry!.kind, undefined); + const before = reload(game); + game = applyAction(game, f.moritani, { type: 'decision', reveal: true }); + game = applyAction(game, f.moritani, { type: 'decision', choice: 'spice' }); + const taken = Math.ceil(player(before, f.opponent).spice / 2); + assert.equal(player(game, f.moritani).spice, player(before, f.moritani).spice + taken); + assert.equal(player(game, f.opponent).spice, player(before, f.opponent).spice - taken); + assert.equal(token(game, 'robbery').status, 'removed'); assert.equal(game.pendingTerrorEntry, null); + assert.equal(player(game, f.opponent).forces[`red_chasm:${sector}`], 3); + assert.equal(game.moritaniAssassinate, undefined); reject(game, f.moritani, { type: 'decision', reveal: true }); custody(game); +}); + +void test('all four minimal legal policies commit actual Cunning and post-loss choices without duplicating custody, rescue, bounty or replacement', () => { + const f = fixture({ tech: true, strongholds: true, stack: true }); + const basic = fixture({ rules: 'basic', stack: true }); + for (const source of [f, basic]) { + const enemy = player(source.game, source.opponent), key = `red_chasm:${territorySector(source.game, 'red_chasm')}`; + assert.ok(enemy.reserves > 0); + enemy.reserves--; + enemy.forces[key] = (enemy.forces[key] ?? 0) + 1; + source.staging.push('Conserved one original Guild reserve counter at Red Chasm as a useful non-stronghold Cunning policy target; not played movement.'); + } + let empty = reload(f.plans); + holdMoritaniStrongholdsTraitor(empty, f.moritani, f.opponentLeader); + empty = resolve(reveal(empty, f.planActions), f.moritani); + for (const difficulty of DIFFICULTIES) { + const action = policy(reload(f.game), f.moritani, difficulty)[0]; assert.ok(action, difficulty); + const placed = allow(applyAction(reload(f.game), f.moritani, action)); + assert.equal(placed.nexusCards!.cards!.hands[f.moritani], null); + assert.equal(placed.moritaniTerror!.placementTurn, 2); assert.equal(placed.nexusMoritaniLast!.stage, 'complete'); + assert.equal(placed.moritaniTerror!.tokens.filter(t => t.status === 'placed').length, 2); + assert.deepEqual(physicalPlayers(placed), physicalPlayers(f.game)); custody(placed); + const basicAction = policy(reload(basic.game), basic.moritani, difficulty)[0]; assert.ok(basicAction, difficulty); + const basicPlaced = allow(applyAction(reload(basic.game), basic.moritani, basicAction)); + assert.equal(basicPlaced.nexusCards!.cards!.hands[basic.moritani], null); + assert.equal(basicPlaced.moritaniTerror!.placementTurn, 2); assert.equal(basicPlaced.moritaniAssassinate, undefined); + assert.deepEqual(physicalPlayers(basicPlaced), physicalPlayers(basic.game)); custody(basicPlaced); + const lossChoice = policy(reload(f.pending), f.moritani, difficulty)[0]; assert.ok(lossChoice, difficulty); + let game = applyAction(reload(f.pending), f.moritani, lossChoice); + assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.stage, 'revealed'); + assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.card, f.target); + assert.equal(player(game, f.opponent).leaders.find(l => l.id === f.target)!.dead, true); + assert.equal(player(game, f.moritani).spice, player(f.pending, f.moritani).spice + 3); + game = finishBattle(game); game = advance(game, s => s.decision?.kind === 'moritaniPlacement'); + assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.stage, 'replaced'); + assert.equal(player(game, f.opponent).tanks, 2); assert.equal(player(game, f.moritani).tanks, 6); + assert.equal(game.leaderSkills!.deck.filter(c => c === 'suk-graduate').length, 1); custody(game); + const decline = policy(reload(empty), f.moritani, difficulty)[0]; assert.ok(decline, difficulty); + const declined = applyAction(reload(empty), f.moritani, decline); + assert.equal(declined.moritaniAssassinate!.opportunities.at(-1)!.stage, 'declined'); + assert.deepEqual(declined.leaderSkills, empty.leaderSkills); + assert.deepEqual(declined.traitorReserve, empty.traitorReserve); + assert.equal(player(declined, f.moritani).spice, player(empty, f.moritani).spice); custody(finishBattle(declined)); + } +}); + +function territorySector(game: Game, id: string): number { + // Only physical board sectors are chosen; the original Storm is never edited. + const site = territory(id); + const sector = site.sectors.find(s => s !== game.storm); assert.ok(sector !== undefined); return sector; +} diff --git a/tests/nexus-ecaz-duke-quote.test.ts b/tests/nexus-ecaz-duke-quote.test.ts index 87b31ebc..c5c8ef7e 100644 --- a/tests/nexus-ecaz-duke-quote.test.ts +++ b/tests/nexus-ecaz-duke-quote.test.ts @@ -102,13 +102,6 @@ void test('native owner sees explicit blocks; no card identity leaks to other se assert.match(quoteNexusEcazDuke({ ...g, battle: {} as Game['battle'] }, 'ecaz-seat')!.blocked!, /current interaction/); assert.match(quoteNexusEcazDuke(g, 'ecaz-seat', true)!.blocked!, /current interaction/); - assert.match(quoteNexusEcazDuke({ ...g, homeworlds: { custody: null } }, 'ecaz-seat')!.blocked!, - /Nexus alone/); - assert.match(quoteNexusEcazDuke({ ...g, players: g.players.slice(0, 2) }, 'ecaz-seat')!.blocked!, - /paired Ecaz and Moritani/); - assert.match(quoteNexusEcazDuke({ - ...g, players: [...g.players, { ...g.players[2], id: 'ixians-seat', faction: 'ixians' }], - }, 'ecaz-seat')!.blocked!, /Nexus alone/); const harkonnen = { ...g, advanced: true, players: [...g.players.slice(0, 2), @@ -118,8 +111,6 @@ void test('native owner sees explicit blocks; no card identity leaks to other se /Advanced Harkonnen/); assert.equal(quoteNexusEcazDuke({ ...harkonnen, advanced: false }, 'ecaz-seat')!.blocked, null); - assert.match(quoteNexusEcazDuke({ ...g, sandtrout: true }, 'ecaz-seat')!.blocked!, - /Nexus alone/); const allied = structuredClone(g); allied.players[0].ally = allied.players[1].id; assert.match(quoteNexusEcazDuke(allied, 'ecaz-seat')!.blocked!, /unallied/); diff --git a/tools/faction-games.ts b/tools/faction-games.ts index b81e27b3..7ecc6f3c 100644 --- a/tools/faction-games.ts +++ b/tools/faction-games.ts @@ -32,7 +32,7 @@ import { type Game, } from '../game/engine'; import { sampleInventory, verifySampleCustody } from './sample-custody'; -import { classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, strongholdLeaderSkillsProfile } from '../game/leader-skill-profile'; +import { classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile } from '../game/leader-skill-profile'; import { createTechTokens } from '../game/tech-tokens'; import { createStrongholdCards } from '../game/stronghold-cards'; import { privateOutputDirectory, sourceSnapshot } from './verification'; @@ -44,7 +44,8 @@ type Profile = 'base' | 'choam' | 'ecaz' | 'ecaz-treachery' | 'ecaz-occupy' | 'm | 'factions-tech' | 'stronghold-factions-tech' | 'nexus-tech' | 'nexus-stronghold' | 'nexus-stronghold-tech' | 'paired-nexus-tech' | 'paired-nexus-stronghold' | 'paired-nexus-stronghold-tech' | 'nexus-skills' | 'paired-nexus-skills' | 'nexus-skills-tech' | 'nexus-skills-stronghold' | 'nexus-skills-stronghold-tech' - | 'paired-nexus-skills-tech' | 'paired-nexus-skills-stronghold' | 'paired-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'; type Rules = 'basic' | 'advanced'; type Scenario = { @@ -132,6 +133,8 @@ const nexusSkillsModuleProfile = (profile: string) => profile === 'nexus-skills- profile === 'nexus-skills-stronghold' || profile === 'nexus-skills-stronghold-tech'; const pairedNexusSkillsModuleProfile = (profile: string) => profile === 'paired-nexus-skills-tech' || profile === 'paired-nexus-skills-stronghold' || profile === 'paired-nexus-skills-stronghold-tech'; +const e3NexusSkillsProfile = (profile: string) => profile === 'e3-nexus-skills' || + profile === 'e3-nexus-skills-tech' || profile === 'e3-nexus-skills-stronghold' || profile === 'e3-nexus-skills-stronghold-tech'; const MODULE_SCENARIOS: readonly Scenario[] = [ ...[2, 3, 4, 5, 6].flatMap((players) => (['basic', 'advanced'] as const).map((rules, index) => ({ @@ -170,15 +173,17 @@ const CLASSIC_NEXUS_MODULE_SCENARIOS: readonly Scenario[] = .map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 800 + band * 100 + index }))); const nexusModuleProfile = (profile: string) => profile === 'nexus-tech' || profile === 'nexus-stronghold' || profile === 'nexus-stronghold-tech' || - pairedNexusModuleProfile(profile) || nexusSkillsModuleProfile(profile) || pairedNexusSkillsModuleProfile(profile); + pairedNexusModuleProfile(profile) || nexusSkillsModuleProfile(profile) || pairedNexusSkillsModuleProfile(profile) || e3NexusSkillsProfile(profile); const nexusTechProfile = (profile: string) => 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 === '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) => 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' || - profile === 'paired-nexus-skills-stronghold' || profile === 'paired-nexus-skills-stronghold-tech'; + profile === 'paired-nexus-skills-stronghold' || profile === 'paired-nexus-skills-stronghold-tech' || + profile === 'e3-nexus-skills-stronghold' || profile === 'e3-nexus-skills-stronghold-tech'; const pairedNexusModuleProfile = (profile: string) => profile === 'paired-nexus-tech' || profile === 'paired-nexus-stronghold' || profile === 'paired-nexus-stronghold-tech'; const EXPANSION_ROSTER_SCENARIOS: readonly Scenario[] = [ @@ -365,6 +370,12 @@ const ECAZ_SKILLS_SCENARIOS: readonly Scenario[] = [ roster: (['ecaz', 'emperor', 'guild', 'atreides', 'fremen', 'beneGesserit'] as FactionId[]).slice(0, players), })), ]; +const E3_NEXUS_SKILLS_SCENARIOS: readonly Scenario[] = + (['e3-nexus-skills', 'e3-nexus-skills-tech', 'e3-nexus-skills-stronghold', 'e3-nexus-skills-stronghold-tech'] as const) + .flatMap((profile, band) => [...ECAZ_SKILLS_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS] + .filter(scenario => (profile === 'e3-nexus-skills' || profile === 'e3-nexus-skills-tech' || scenario.rules === 'advanced') && + (profile === 'e3-nexus-skills' || profile === 'e3-nexus-skills-stronghold' || scenario.roster.length >= 3)) + .map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 2100 + band * 100 + index }))); const STRONGHOLD_FACTIONS_ROSTER: readonly FactionId[] = [ 'ixians', 'choam', 'emperor', 'fremen', 'harkonnen', 'beneGesserit', ]; @@ -441,7 +452,7 @@ const SKILLS_STRONGHOLD_SCENARIOS: readonly Scenario[] = ].filter(scenario => profile !== 'skills-stronghold-tech' || scenario.roster.length >= 3) .map((scenario, index): Scenario => ({ ...scenario, ordinal: 376 + band * 100 + index, profile }))); const strongholdSkillProfile = (profile: string) => profile === 'skills-stronghold' || profile === 'skills-stronghold-tech'; -const skillProfile = (profile: string) => 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) => 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 assassinationScenario = (scenario: Scenario) => scenario.rules === 'advanced' && scenario.roster.includes('moritani') && (scenario.profile === 'moritani-assassinate' || nativeStrongholdProfile(scenario.profile) || nativeTechProfile(scenario.profile) || skillProfile(scenario.profile)); @@ -482,7 +493,7 @@ type Result = { function usage() { return ( 'Usage: node --import tsx tools/faction-games.ts --out NEW_PRIVATE_DIR ' + - '[--seed UINT32] [--profile all|base|choam|ecaz|ecaz-treachery|ecaz-occupy|moritani-assassinate|stronghold-factions|factions-tech|stronghold-factions-tech|combined|combined-nexus|combined-homeworld-nexus|homeworld|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|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] ' + + '[--seed UINT32] [--profile all|base|choam|ecaz|ecaz-treachery|ecaz-occupy|moritani-assassinate|stronghold-factions|factions-tech|stronghold-factions-tech|combined|combined-nexus|combined-homeworld-nexus|homeworld|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' + 'Runs genuine setup and gameplay offline. Default/all keeps the six expansion samples; combined-nexus and combined-homeworld-nexus add five/six-seat Basic/Advanced all-expansion samples. Base, Homeworld, Nexus, Homeworld-Nexus, expansion roster, paired expansion Nexus, Ecaz card variant, Advanced Moritani assassination and skill profiles select their documented rosters. Stronghold-factions is Advanced only: Ixians + CHOAM, native Ixians + Tleilaxu, or native CHOAM + Richese with classic opponents, two through six seats; mixed four-native rosters also run at four through six seats. Selected Ix/CHOAM decks determine the canonical 47/35 Treachery Cards; Richese has a separate ten-card cache and all samples have six separate Stronghold Cards without other modules. Scenario names identify their native roster. --players requires a supported profile. Output must be a new private directory outside the checkout.' + @@ -500,6 +511,7 @@ function usage() { + '\nPaired-nexus-skills adds all14 Skills to both original Ixians+Tleilaxu OR CHOAM+Richese, classic opponents and one family deck/all12 Nexus, Basic/Advanced2..6. Native setup, actual closing draws, typed skills/Cunning, Face Dancers and one-invoice physical marker pairs reuse original rules. Tech/Strongholds/HW, mixed families, E3, Banker/Mentat previews, borrowed Smuggler/pair companions, public starts and pending rulings remain excluded.' + '\nNexus-skills-tech preserves original Tech3..6 Basic/Advanced with classic/base33/all14/all12; nexus-skills-stronghold preserves original Advanced Strongholds2..6; selecting both requires3..6 Advanced. Real first-Storm/end-Mentat ownership, phase-end industry, held subsidy, typed rescue and mandatory original-winner token transfer remain. Native rosters, HW, Banker/Mentat previews, other overlays, public starts and pending rulings stay separate.' + '\nPaired-nexus-skills-tech preserves original Tech3..6 Basic/Advanced with both Ixians+Tleilaxu OR CHOAM+Richese and classics, one original family deck/all14/all12. Paired-nexus-skills-stronghold uses Advanced2..6; both requires3..6 Advanced. Native setup, actual closing deals, typed rescue/equal substitution, original signed marker pairs, phase-end industry, held support and original winner reward before Face Dance remain. No mixed families/E3/HW/previews/companions/public starts or new rulings.' + + '\nE3-nexus-skills composes standalone Ecaz OR Moritani and classics, ecaz33/all14/all12, Basic/Advanced2..6. Tech requires3+; Strongholds requires Advanced; both3+ Advanced. Original Duke/assignment and Moritani assassination/normal-traitor-forfeit guards remain, including Advanced Harkonnen exclusion. Real closing draws, first-Storm/end-Mentat custody, source skill effects and original payments/rewards remain. No E3 pair/mixed families/HW/independent cards/previews/public starts or new rulings.' ); } @@ -554,6 +566,8 @@ function scenarioName(scenario: Scenario) { const suffix = native.length > 1 ? '-' + native.join('-') : scenario.expansions.includes('ix') ? '-ix-deck' : ''; return `${scenario.profile}${suffix}-${scenario.roster.length}-${scenario.rules}`; } + if (e3NexusSkillsProfile(scenario.profile)) + return `${scenario.profile}-${scenario.roster[0]}-${scenario.roster.length}-${scenario.rules}`; if (pairedNexusSkillsModuleProfile(scenario.profile) || pairedNexusModuleProfile(scenario.profile) || scenario.profile === 'paired-nexus-skills') { const family = scenario.expansions[0] === 'ix' ? 'ixians-tleilaxu' : 'choam-richese'; return `${scenario.profile}-${family}-${scenario.roster.length}-${scenario.rules}`; @@ -672,7 +686,7 @@ function resumedGame(path: string) { throw new Error( '--resume is not an incomplete faction-games snapshot with saved AI profiles.', ); - const composedSkills = (classicNexusLeaderSkillsProfile(game) || classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game) || strongholdLeaderSkillsProfile(game)) && !game.spiceBankerIncomePreview; + const composedSkills = (standaloneE3NexusLeaderSkillsProfile(game) || classicNexusLeaderSkillsProfile(game) || classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game) || strongholdLeaderSkillsProfile(game)) && !game.spiceBankerIncomePreview; if ( (game.leaderSkills && !nativeExpansionLeaderSkillsProfile(game) && !composedSkills) || game.discoveryEnabled || @@ -683,7 +697,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].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].find( (candidate) => (candidate.profile === 'homeworld' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.homeworlds && (candidate.profile === 'paired-nexus-skills' || candidate.profile === 'nexus-skills' || candidate.profile === 'nexus' || nexusModuleProfile(candidate.profile) || candidate.profile === 'homeworld-nexus' || candidate.profile === 'choam-nexus' || candidate.profile === 'ecaz-nexus' || candidate.profile === 'ix-nexus' || candidate.profile === 'combined-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.nexusCards && @@ -917,6 +931,7 @@ async function main() { : profile === 'paired-nexus-skills' ? PAIRED_NEXUS_SKILLS_SCENARIOS : pairedNexusSkillsModuleProfile(profile) ? PAIRED_NEXUS_SKILLS_MODULE_SCENARIOS : nexusSkillsModuleProfile(profile) ? NEXUS_SKILLS_MODULE_SCENARIOS + : e3NexusSkillsProfile(profile) ? E3_NEXUS_SKILLS_SCENARIOS : nexusModuleProfile(profile) ? CLASSIC_NEXUS_MODULE_SCENARIOS : profile === 'choam-roster' || profile === 'ecaz-roster' || profile === 'ix-roster' ? EXPANSION_ROSTER_SCENARIOS : profile === 'ecaz-treachery' ? ECAZ_TREACHERY_SCENARIOS diff --git a/tools/start-prototype.ts b/tools/start-prototype.ts index 254bad66..348f0f40 100644 --- a/tools/start-prototype.ts +++ b/tools/start-prototype.ts @@ -33,8 +33,10 @@ async function main() { '\nWithout Skills, factions preserves unused original Tech Tokens in fresh 3–6-seat Basic/Advanced selected E1/E2 native or standalone Ecaz OR Moritani lobbies with classic opponents; stronghold-factions composes them with Advanced Stronghold Cards. Original physical inventories, first-Storm owners, selected Ecaz lead rewards, even-force Basic Occupy and non-Harkonnen Advanced Moritani assassination remain. Skills, unrelated overlays, paired/mixed E3 rosters and public starts remain separate.', '\nNexus preserves selected unused original Tech Tokens in classic/base33/all12 three-through-six-seat Basic/Advanced lobbies, and/or unused Stronghold Cards in Advanced two-through-six-seat lobbies; both requires3+. Genuine first-Storm/end-Mentat custody and closing Nexus deals remain. Borrowed tariff/force identity, original free-return ledgers, phase-end industry, held support and retention/winner rewards reuse existing handlers. Skills, Homeworlds in this composition, expansions, unrelated previews, unresolved effects and public starts remain separate.', '\nNexus additionally routes a supported paired native E1 (Ixians+Tleilaxu) OR E2 (CHOAM+Richese) lobby with classics and one required deck through original paired setup when Tech and/or Advanced Strongholds is selected. Tech requires3–6 Basic/Advanced, Strongholds2–6 Advanced, both3–6 Advanced. All12 Nexus, original47/35 decks/cache, native setup and actual module custody remain. Mixed families, E3, Skills, other overlays, public starts and pending rulings stay separate.', - '\nLeader-skills --nexus-cards explicitly adds all12 Nexus to fresh classic/base33 OR paired Ixians+Tleilaxu/Ix47 OR paired CHOAM+Richese/CHOAM35+cache setups with classics and all14 skills, two-through-six seats Basic/Advanced. Original starting hands/offers, skill/Traitor/native order and genuine closing deals remain. Mixed families, E3, HW, Banker/Mentat previews, borrowed Smuggler/pair-companion arithmetic, public starts and unresolved interactions stay separate.', + '\nLeader-skills --nexus-cards explicitly adds all12 Nexus to fresh classic/base33 OR paired Ixians+Tleilaxu/Ix47 OR paired CHOAM+Richese/CHOAM35+cache setups with classics and all14 skills, two-through-six seats Basic/Advanced. Original starting hands/offers, skill/Traitor/native order and genuine closing deals remain. Mixed families/E3 pairs, HW, Banker/Mentat previews, borrowed Smuggler/pair-companion arithmetic, public starts and unresolved interactions stay separate.', '\nThese classic or paired one-family skill-Nexus entries preserve selected unused original Tech Tokens at3–6 Basic/Advanced and/or Stronghold Cards at2–6 Advanced; both require3–6 Advanced. Real first-Storm/end-Mentat custody, phase-end industry, held subsidy, typed skill rescue/equal substitution, signed one-invoice marker pairs and mandatory original-winner Tech before Face Dance use original handlers. No mixed-family/native free-return accounting or pending rulings are opened.', + '\nThe same fresh skill/Nexus entry admits standalone Ecaz OR Moritani with classics, exact ecaz33/all14/all12, Basic/Advanced2–6. Optional unused Tech requires3+; Strongholds requires Advanced; both3+ Advanced. Native Ecaz five-disc/Duke assignment and quiet living-Duke Cunning, plus original Advanced Moritani skill-first assassination/normal-traitor forfeiture, remain. Advanced Harkonnen, shared-Duke assignment, enhanced Terror relocation/HMS/Grumman and other pending effects stay guarded.', + '\nIn the standalone Ecaz skill/Nexus profile, mandatory Advanced Occupy composes the chosen faction’s skill with separately labelled armies. Ecaz-led Suk rescues its own fixed casualties after the ally’s variable losses; Diplomat uses the chosen faction’s undialed own pool. Shared training, Basic odd rounding and other pending effects remain separate.', ); return; }