From 2d24f4372196515b4b4b07555e5af518a209e18b Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:08:58 +1100 Subject: [PATCH] Compose paired Nexus Skills with original Tech and Strongholds --- docs/CURRENT_STATUS.md | 44 ++ docs/LEADER_SKILLS_RUNTIME.md | 24 + docs/NEXUS_CARD_RULES.md | 89 ++++ docs/RULE_DECISIONS.md | 1 + game/engine.ts | 2 +- game/leader-skill-profile.ts | 10 +- game/reference.ts | 6 +- tests/fixture-native-skills-stronghold.ts | 16 +- ...xture-paired-choam-nexus-skills-modules.ts | 239 +++++++++ .../fixture-paired-ix-nexus-skills-modules.ts | 459 ++++++++++++++++++ ...choam-nexus-skills-modules-runtime.test.ts | 456 +++++++++++++++++ ...ed-ix-nexus-skills-modules-runtime.test.ts | 351 ++++++++++++++ tools/faction-games.ts | 29 +- tools/start-prototype.ts | 4 +- 14 files changed, 1709 insertions(+), 21 deletions(-) create mode 100644 tests/fixture-paired-choam-nexus-skills-modules.ts create mode 100644 tests/fixture-paired-ix-nexus-skills-modules.ts create mode 100644 tests/paired-choam-nexus-skills-modules-runtime.test.ts create mode 100644 tests/paired-ix-nexus-skills-modules-runtime.test.ts diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index eebad4ba..eefc7695 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,42 @@ open mode gates. ## Current checkpoint and work +**Paired E1/E2 Nexus / Skills / Tech / Strongholds — bounded Development Verified:** +both Ixians+Tleilaxu OR CHOAM+Richese plus classics, one original family47/ +35+cache deck/all14/all12, selected unused Tech3..6 Basic/Advanced and/or +Strongholds2..6 Advanced; both3..6 Advanced. Original setup/offers/hands/IDs, +real closing draws, typed rescue/equal substitution, signed pair/one invoice, +phase-end industry and original winner reward-before-FaceDance remain. +New50/affected175, types/lint/build,34 original setups/26 retained prerequisite +rejects and four legal policies pass. Thirty-four genuine2..6 games complete +16,094 accepted actions/no rejection,420 JSON continuations and22 actual +closing choices on unchanged tree +`472b5705ca5376fb95f8e6ff6f40192d9f1745d7d08c41efdef2b3e53783139a`. +An older native marker fixture's unseeded scalar actions are now controlled; +all nine native cases and the frozen175-case union pass afterward. +Original backed-up CLI **PPL5CNEB / TZRU5GYP v10/setup** retains actual +offers/hands/IDs after a scoped original14 shuffle. Human390px **PPL5CNEB +v28/Collection** copies held Arrakeen into HMS, uses full Suboids and trained +C’tair5/dial4/support1 (bank1/own0), commits3 actual Cyborg losses, keeps1 +through Suk and exchanges only the actual2 Tank losses for2 Suboids. +HMS ends1 Suboid/3 Cyborgs,12 reserves/2 ordinary Tanks,4 Cyborg reserves/ +zero Cyborg Tanks. Guild Production transfers to original Ix winner; +held Arrakeen/HMS remain Ix. Support leaves wallet10, separate city2 pays12. +Actual original Face Dancer stock has no matching trainer and remains unchanged; +matching trainer death/skill return and partial Cunning are separately exercised +native source programs, not claimed as human or natural shuffle results. +**TZRU5GYP v21/Battle** declares actual immediate5/concealed3 at Habbanya17, +passes real Karama and separate Guild allowance, pays Richese4→3/Guild6→7, +reveals3 into real ordinary counters (12 reserves/8 board/last-used3). +CHOAM then spends actual Hajr as Kulon fuel, passes the native response and +moves one ordinary force WindPass14→Bight14 via legal Planetologist range3. +Hajr/Nexus are spent once; original market completion pays Heighliners1, +CHOAM19→20, not at declaration or fuel/movement. Original skill custody stays. +Controls/inspectors/refresh/source guide have no root390px overflow; only QA +rooms changed. Mixed families/E3/HW/previews, borrowed companions, native +Nexus free-return accounting, pending rulings and public/deployed gates remain. +[Canonical source and evidence](NEXUS_CARD_RULES.md#paired-nexus-skills-and-original-techstrongholds--5-october-2026). + **Classic Nexus / Skills / Tech / Strongholds — bounded Development Verified:** original fresh skill/Nexus setup retains selected unused Tech3..6 Basic/Advanced and/or Strongholds2..6 Advanced; both require3..6. Original base33/all14/all12, @@ -45,6 +81,14 @@ Only dedicated QA rooms changed. Native overlays, HW, Banker/Mentat previews, borrowed Smuggler arithmetic, pending rulings, public/save conversion and full module/deployed acceptance stay separate. [Canonical scope and evidence](NEXUS_CARD_RULES.md#classic-nexus-skills-and-original-techstrongholds--4-october-2026). +Exact code **449e27b4151bf63355c2e5a270a0dd63c565f175** is pushed. +Final48 cases/types/lint and pushed build pass; rebuilt QA displays449e27b +and both unchanged original outcomes at390px. +[CI37204049860](https://github.com/Quintine/Dune/actions/runs/37204049860) / +container111441442571 completes successfully, isolated storage/HTTP step7 +before verified image publication step9. No deployed claim; last live +observation remains **ab5c782**, with NAS prerequisites unchanged. + **Paired E1/E2 Nexus / Leader Skills — bounded Development Verified:** both Ixians+Tleilaxu OR CHOAM+Richese plus classics, one family deck/all14/all12, Basic/Advanced2..6 through original `leader-skills --nexus-cards`, no overlays. diff --git a/docs/LEADER_SKILLS_RUNTIME.md b/docs/LEADER_SKILLS_RUNTIME.md index 344f42ab..ae6737cf 100644 --- a/docs/LEADER_SKILLS_RUNTIME.md +++ b/docs/LEADER_SKILLS_RUNTIME.md @@ -29,6 +29,30 @@ Advanced native/combined-module skills remain guarded. See [native preview scope](MENTAT_QUESTION.md#native-skill-integration--2-october-2026). +## Paired Nexus with original Tech/Strongholds — 5 October 2026 + +Fresh original paired Ixians+Tleilaxu OR CHOAM+Richese skill/Nexus entries +preserve selected unused original Tech3..6 Basic/Advanced and/or Strongholds2..6 +Advanced, both3..6 Advanced. One original family deck/all14/all12 and saved +hands/offers/IDs remain. This extends paired Skills-only evidence below, +without admitting mixed families/E3/HW/previews/public starts or pending rulings. + +Original Cunning/typed rescue/equal substitution and reward-before-FaceDance, +signed physical marker pair/one invoice/native industry and held effects +retain their independent handlers. Bank subsidy changes debit, not committed +support or rescue entitlement. Captured-training revival remains guarded; +no foreign replacement, borrowed companion or native free-return ruling is added. +New50/affected175, types/lint/build,34 original setups/26 prerequisite rejects, +four legal policies and34 genuine2..6 games pass:16,094 actions/no rejection, +420 JSON continuations/22 closing choices. Human390px PPL5CNEB keeps original +bank-funded commitment through real Cyborg rescue/equal substitution and +winner reward; unmatched Face Dancer stock remains unchanged. Matching trainer +replacement/partial Cunning is a separate native source program. TZRU5GYP +preserves signed one-invoice marker settlement and physical reveal, then spends +actual Hajr once as CHOAM fuel before original Planetologist range3 movement +and phase-end Heighliners collection. Refreshed outcomes and guide remain. +See [canonical paired combined scope](NEXUS_CARD_RULES.md#paired-nexus-skills-and-original-techstrongholds--5-october-2026). + ## Classic Nexus with original Tech/Strongholds — 4 October 2026 Fresh classic `leader-skills --nexus-cards` now preserves selected unused diff --git a/docs/NEXUS_CARD_RULES.md b/docs/NEXUS_CARD_RULES.md index 9467bc64..75f53df8 100644 --- a/docs/NEXUS_CARD_RULES.md +++ b/docs/NEXUS_CARD_RULES.md @@ -2,6 +2,86 @@ 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. +## Paired Nexus, Skills and original Tech/Strongholds — 5 October 2026 + +The original fresh `leader-skills --nexus-cards` entry now preserves selected +unused original Tech3..6 Basic/Advanced and/or Strongholds2..6 Advanced with +**both Ixians+Tleilaxu OR both CHOAM+Richese**, classic opponents and one +original family47/35+separatecache10 deck/all14 Skills/all12 Nexus. Both +requires3..6 Advanced. This extends the earlier paired Skills-only checkpoint, +not mixed families, E3, Homeworlds, independent decks/previews, public starts, +played-game conversion or pending native free-return accounting. + +Original setup/hands/offers/actor IDs and genuine closing Nexus draws remain. +First-Storm printed/real Tech allocation and actual end-Mentat held-card +assignment are required; admission grants neither. Independent physical rules +compose in original order: full unsupported Suboid Cunning, typed skill rescue, +equal substitution of only actual Cyborg Tank losses, card cleanup and original +winner Tech before Face Dance. Original held support changes payer debit, not +the commitment used by casualties or Suk. A matching winning trainer dies and +returns its skill once without another bounty; reward and held custody remain +with the original winner. Partial Tleilaxu refresh preserves unrevealed stock +and ordinary Mentat replacement rights. + +Richese's original signed two-marker/one-invoice/native response/Guild +continuation remains authoritative, without single-marker skill proof, +new frame keys or relaxed signatures. Actual off-planet shipment triggers +Heighliners once at phase end; voluntary original reveal leads to ordinary +skill battles. CHOAM physical Special fuel precedes its separate ordinary +Planetologist movement; native paid support income retains bank-funded support. +Companions, own mixed plans and unrelated unresolved reactions stay 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-paired-nexus-skills-modules-entry +node --import tsx tools/faction-games.ts --profile paired-nexus-skills-tech \ + --players all --rules both --out /private/new-paired-nexus-skills-tech-games +``` + +`paired-nexus-skills-stronghold` is Advanced2..6; +`paired-nexus-skills-stronghold-tech` is Advanced3..6. Original module +selection precedes fresh setup. Thirty-four original setup programs and26 +retained prerequisite rejections are development evidence, not complete +rules/public/deployed certification. + +### Bounded paired combined evidence + +New50/affected175, types/lint/build,34 original setup programs/26 retained +prerequisite rejects and four legal policies pass. Thirty-four genuine2..6 +games complete16,094 accepted actions/no rejection,420 JSON continuations and +22 actual closing choices. Natural Cunning combinations are not claimed. +Reports under `/tmp/dune-paired-nexus-skills-{tech,stronghold,both}-games-20261005` +capture unchanged tree `472b5705ca5376fb95f8e6ff6f40192d9f1745d7d08c41efdef2b3e53783139a`. +An older native marker fixture's scalar action entropy is controlled after an +intermittent missing-marker assertion; its nine cases and final175 union pass. +No production marker behavior or unresolved ruling is changed for that fixture. + +Original backed-up CLI **PPL5CNEB / TZRU5GYP v10/setup** preserves first +offers/hands/IDs after only the original14 pre-deal shuffle. Controlled390px +**PPL5CNEB v28/Collection** copies held Arrakeen into HMS, spends actual Suboid +Cunning and seals trained C’tair5/dial4/support1: bank1/own0. Three actual +Cyborg losses save1 kept; only2 go to Tanks and are recovered for2 Suboids. +HMS ends1 Suboid/3 Cyborgs;12 reserves/2 ordinary Tanks,4 Cyborg reserves/ +zero Cyborg Tanks. Original winner receives Guild Production and retains +held Arrakeen/HMS. Wallet10 remains through bank-funded support, then city2 +pays12. Actual unmatched Face Dancer stock is not manufactured or exchanged; +the human decline is separate from a labeled matching-stock native program +that proves trainer death/one skill return, retained reward/custody and partial +Cunning preserving the two unrevealed originals. + +**TZRU5GYP v21/Battle** selects immediate5/concealed3, Habbanya17, actual +Karama allowance and distinct native Guild allowance. One invoice debits +Richese4→3/credits Guild6→7; voluntary3 reveal leaves12 reserve/8 ordinary +board/last-used3. Original Suk/Planetologist assignments stay unchanged. +CHOAM uses physical Hajr as Kulon Cunning fuel, passes the native response, +then moves one ordinary force WindPass14→Bight14 via the original legal +Planetologist range3. Hajr is discarded once without activating its extra move. +Original CHOAM market completion settles Heighliners1,19→20, only at movement +end. Original controls/inspectors/refresh/guide have no root390px overflow. +Only dedicated QA rooms change; all controlled physical relocation/order is +labeled rather than claimed as natural history. + ## Classic Nexus, Skills and original Tech/Strongholds — 4 October 2026 The original fresh `leader-skills --nexus-cards` entry now preserves selected @@ -72,6 +152,15 @@ end pays12→13 exactly once. Actual controls/inspectors/refresh/updated source guide have no root390px overflow. Only dedicated QA rooms changed; controlled physical relocation/order is labeled, not claimed as natural history. +Exact code **449e27b4151bf63355c2e5a270a0dd63c565f175** is pushed. +Final48 cases/types/lint and exact pushed build pass; rebuilt QA displays +**449e27b** and both original outcomes at390px after reload. +[CI37204049860](https://github.com/Quintine/Dune/actions/runs/37204049860) / +container111441442571 completes successfully, isolated storage/HTTP step7 +before verified image publication step9. Publication is not deployment; +last live observation remains **ab5c782** and required NAS prerequisites +are unchanged. Full-module/public/deployed gates remain open. + ## Paired E1/E2 Nexus and Leader Skills composition — 4 October 2026 The original fresh `leader-skills --nexus-cards` entry additionally admits diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 1463f47d..662bf3ea 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -208,6 +208,7 @@ See [the cutover and Advanced sequence](MORITANI_ASSASSINATE_LEADERS.md#authorit | Classic Nexus / Tech / Strongholds | Fresh classic/base33/all12 Nexus may preserve original Tech3..6 Basic/Advanced and/or Strongholds2..6 Advanced, without Skills/HW/expansion decks or unrelated previews. Original closing deals, first-Storm Tech and actual end-Mentat card claims remain. Actual Nexus free-three returns invoke Axlotl without merging their extra/ordinary ledgers; industry collects once at native phase end. Borrowed tariffs never relocate Fremen on-planet reserves or native faction identity. Held Arrakeen pays actual support; temporary Sardaukar remain ordinary counters. Losing Moritani retention precedes original winner Tech, then CHOAM excludes every used opposing card. Bank purchase retains actual wallet/card without an industry trigger. Pending borrowed Voice, worm protection and Tleilaxu revival remain guarded; no public starts/save conversion or new ruling. | [Composition/source and bounded evidence](NEXUS_CARD_RULES.md#classic-tech-and-stronghold-composition--4-october-2026) | | Paired E1/E2 Nexus / Tech / Strongholds | Fresh both Ixians+Tleilaxu OR CHOAM+Richese plus classics with one original family deck/all12 Nexus preserves Tech3..6 Basic/Advanced and/or Strongholds2..6 Advanced, without Skills or other overlays. Original Cunning, typed Suboid/Cyborg loss and equal substitution, Face Dancer reward-before-replacement and draw-before-retirement, two physical No-Fields/one tariff, native industry at phase end and actual held/copy effects reuse original handlers. No mixed families, E3, new interpretations, public starts or played-game conversion. | [Scope, sources and bounded evidence](NEXUS_CARD_RULES.md#paired-e1e2-tech-and-stronghold-composition--4-october-2026), [Richese composition](NEXUS_RICHESE_RULES.md#paired-techstronghold-composition--4-october-2026) | | Classic Nexus / Skills / Tech / Strongholds | Fresh classic original skill/Nexus entry retains selected unused Tech3..6 Basic/Advanced and/or Strongholds2..6 Advanced. Original setup/closing deals/owner timing, distinct free returns and phase-end industry remain. Held subsidy changes actual payer debit, not committed support/Suk entitlement; physical rescue precedes card cleanup and mandatory winner token choice. Original once-per-phase Bureaucrat diversion composes player payments. Captured-training leader revival remains blocked; public Ghola/ordinary options now omit those illegal choices rather than strand legal AI passes. Paired native combined components, HW, other previews, public/save conversion and new rulings stay guarded. | [Combined source contract](NEXUS_CARD_RULES.md#classic-nexus-skills-and-original-techstrongholds--4-october-2026) | +| Paired Nexus / Skills / Tech / Strongholds | Both Ixians+Tleilaxu OR CHOAM+Richese plus classics and one original family deck/all14/all12 preserve selected unused Tech3..6 Basic/Advanced and/or Strongholds2..6 Advanced through the original fresh skill/Nexus entry. Typed rescue precedes equal actual Cyborg Tank substitution, original card cleanup/mandatory winner Tech precedes Face Dance; held subsidy changes debit, not committed support, and reward/custody stay original winner. Signed marker pair/one invoice/native industry retains original timing. Bounded runtime/program/phone/genuine-game evidence does not certify full modules; mixed families/E3/HW/previews/companions/native Nexus free-return rulings/public start/save conversion stay guarded. | [Paired combined contract and evidence](NEXUS_CARD_RULES.md#paired-nexus-skills-and-original-techstrongholds--5-october-2026) | | Classic Nexus / Leader Skills | Fresh classic/base33/all14 Skills/all12 Nexus, Basic/Advanced2..6, explicit `leader-skills --nexus-cards`, no other overlays/previews. Original starting hands/offers/skill-before-Traitor setup and actual closing Nexus draw remain. Exact extra/ordinary free-return ledgers, real bank purchase and committed Traitor declarations preserve trained-disc custody. Temporary ordinary-as-Sardaukar roles compose actual skill posture/bonuses/support; matching Cunning casualty receipt stays pending through original Suk rescue and completes only after physical settlement. Source-guarded borrowed Smuggler arithmetic, Atreides Suk/KH and unresolved effects remain; no public start, retrofit or new ruling. | [Scope, sources and bounded evidence](NEXUS_CARD_RULES.md#classic-nexus-and-leader-skills-composition--4-october-2026), [Skill runtime](LEADER_SKILLS_RUNTIME.md#classic-nexus-composition--4-october-2026) | | Paired E1/E2 Nexus / Leader Skills | Fresh both Ixians+Tleilaxu OR CHOAM+Richese plus classics, one original family deck/all14 Skills/all12 Nexus, Basic/Advanced2..6, explicit original skill entry. Actual native setup/offers/closing draws remain. Full unsupported Suboids compose typed Suk rescue before equal actual Tank substitution; Face Dance follows original winner outcomes, kills/returns the winning trainer skill once without another bounty, and partial Cunning retains ordinary Mentat rights. Richese pair uses its original signed two-token frame/one invoice/native response/Guild continuation, not single-marker skill proof; CHOAM printed fuel/Planetologist physical route remain separate. No other overlays, companion/mixed-plan/new ruling/public starts or retrofit. | [Scope, sources and bounded evidence](NEXUS_CARD_RULES.md#paired-e1e2-nexus-and-leader-skills-composition--4-october-2026), [Skill runtime](LEADER_SKILLS_RUNTIME.md#paired-e1e2-nexus-composition--4-october-2026) | | Discoveries | Preserve seven sourced Spice Cards and eight physical tokens, private faction peeks and Collection choices. Revealed locations are separate territories; only Jacurutu counts toward stronghold victory. Next-turn free entry, sole-occupant Cistern income, bounded sole-occupant Orgiz transfer and later-turn Ornithopter spending are prototyped; Jacurutu income, Testing Station and Shrine also have bounded paths. Contested and mixed cases remain pending. | [Component authority](DISCOVERY_COMPONENTS.md), [prototype boundary](DISCOVERY_PROTOTYPE.md) | diff --git a/game/engine.ts b/game/engine.ts index 65a5ee85..80817371 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -9443,7 +9443,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))); + (leaderSkills && (classicNexusLeaderSkillsProfile(g) || pairedNexusLeaderSkillsProfile(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.'); diff --git a/game/leader-skill-profile.ts b/game/leader-skill-profile.ts index a7dfaf47..e790d5a1 100644 --- a/game/leader-skill-profile.ts +++ b/game/leader-skill-profile.ts @@ -33,13 +33,13 @@ export function noOtherLeaderSkillModules(game: LeaderSkillProfile): boolean { !game.discoveryEnabled && !game.strongholdCards && !game.techTokens && !game.ecazTreachery; } -function nexusLeaderSkillModulesSupported(game: LeaderSkillProfile, tokenCards = false): boolean { +function nexusLeaderSkillModulesSupported(game: LeaderSkillProfile): 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 || (tokenCards && game.players.length >= 3)) && - (!game.strongholdCards || (tokenCards && game.advanced === true)) && + (!game.techTokens || game.players.length >= 3) && + (!game.strongholdCards || game.advanced === true) && !game.ecazTreachery && !game.semutaPreview && !game.moritaniAssassinatePreview && !game.advancedPreview && !game.kullPreview && !game.nexusKullPreview && !game.guildBetrayalPreview && !game.richeseBetrayalPreview && @@ -50,7 +50,7 @@ function nexusLeaderSkillModulesSupported(game: LeaderSkillProfile, tokenCards = /** Fresh classic Nexus/Skills, with original Tech and Advanced Strongholds; native overlays stay separate. */ export function classicNexusLeaderSkillsProfile(game: LeaderSkillProfile): boolean { - if (game.expansions.length || !nexusLeaderSkillModulesSupported(game, true)) return false; + if (game.expansions.length || !nexusLeaderSkillModulesSupported(game)) return false; const players = game.players!; for (let i = 0; i < players.length; i++) { for (let j = 0; j < i; j++) if (players[j].faction === players[i].faction) return false; @@ -62,7 +62,7 @@ export function classicNexusLeaderSkillsProfile(game: LeaderSkillProfile): boole return true; } -/** Both original natives in one E1/E2 family; independent decks and other overlays stay separate. */ +/** Both original natives in one E1/E2 family, with original Tech/Advanced Strongholds; other overlays stay separate. */ export function pairedNexusLeaderSkillsProfile(game: LeaderSkillProfile): boolean { if (game.expansions.length !== 1 || !nexusLeaderSkillModulesSupported(game)) return false; const expansion = game.expansions[0]; diff --git a/game/reference.ts b/game/reference.ts index 7c10136e..dade52fd 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -4368,7 +4368,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Fresh local classic Nexus tables may preserve original Tech Tokens in Basic/Advanced three-through-six-seat lobbies and/or Stronghold Cards in Advanced two-through-six-seat lobbies; selecting both requires three or more. Original base33/all12 setup, closing Nexus deals, first-Storm Tech and actual end-Mentat Stronghold custody remain. Skills, Homeworlds in this composition, native expansions, unrelated previews, public starts and saved-game conversion stay separate.', '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 also supports paired Ixians + Tleilaxu OR CHOAM + Richese with classics and one original family deck/all14/all12, Basic/Advanced two through six seats, without other overlays. Full Suboid Cunning composes typed skill rescue before equal actual Cyborg Tank substitution. Face Dance follows original skill/card/reward settlement, kills the matching trainer and returns its skill once without another bounty; partial Tleilaxu Cunning preserves ordinary Mentat rights. Original Richese pair and CHOAM printed fuel use their distinct physical operations. Mixed families, E3, companion/mixed-plan, native free-return accounting and other pending rulings stay separate.', + '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.', '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 +4419,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'] }, + { 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'] }, ], }, { @@ -4607,7 +4607,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Fresh three-through-six-seat Basic/Advanced development tables may compose all fourteen Skills with original Tech Tokens: classic factions, supported E1/E2 native families, or standalone Ecaz/Moritani with the exact ecaz deck and original roster exclusions. Starting cards/cache, skill/Traitor choices and printed/first-Storm assignment stay genuine. Existing leader-skills/banker-income entry preserves unused selected tokens; mixed E3 families, other overlays, public activation and saved-game conversion remain separate.', 'Fresh Advanced classic or supported native two-through-six-seat skill tables may retain six original Stronghold Cards, with Tech at three or more seats. Original end-Mentat/local held effects remain. Arrakeen changes actual payer cost, not rescue or Banker eligibility; native CHOAM income retains bank support. Physical defenses, normal disciplines, holder ties and original dial/Worthless earnings remain. Standalone Ecaz Duke and Moritani assassination compose original rules; mixed E3, allied Occupy skills, copied Diplomat defenses, active skill/Stone and other guards stay separate.', 'Fresh classic Leader Skills may explicitly add Nexus through local leader-skills --nexus-cards, Basic/Advanced two through six players, retaining selected original Tech at three or more seats and/or Advanced Strongholds. 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.', - 'The original paired Ixian/Tleilaxu or CHOAM/Richese native skill roster may also add Nexus alone through that explicit fresh local entry, with classics and one original family deck, Basic/Advanced two through six players. Full unsupported Suboids retain Cyborg support and skill posture. Suk physical rescue finishes before equal substitution of only actual Cyborg Tank losses; Face Dance then returns a killed trainer skill once without another bounty. Richese Cunning preserves its signed two-marker/one-invoice operation; ordinary skill marker proofs and companion exclusions remain separate. CHOAM Special fuel is discarded before an ordinary Planetologist move, without a second card effect or created combat Special.', + '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.', '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/tests/fixture-native-skills-stronghold.ts b/tests/fixture-native-skills-stronghold.ts index 2f44dcee..621c84b5 100644 --- a/tests/fixture-native-skills-stronghold.ts +++ b/tests/fixture-native-skills-stronghold.ts @@ -102,7 +102,18 @@ export function nextNativeSkillsStrongholdStep(game: Game): StrongholdFactionsNa } function act(game: Game, next: StrongholdFactionsNativeStep, actions?: StrongholdFactionsNativeStep[]): Game { actions?.push(structuredClone(next)); - return applyAction(game, next.actor, next.action); + const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); + const native = crypto.getRandomValues.bind(crypto); + crypto.getRandomValues = (array: V): V => { + if (array instanceof Uint32Array) array.fill(0xffffffff); + else Reflect.apply(native, crypto, [array]); + return array; + }; + try { return applyAction(game, next.actor, next.action); } + finally { + if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); + else Reflect.deleteProperty(crypto, 'getRandomValues'); + } } export function advanceNativeSkillsStronghold(state: Game, until: (g: Game) => boolean, actions?: StrongholdFactionsNativeStep[], firstDial = 0): Game { @@ -197,7 +208,8 @@ export function createNativeSkillsStrongholdFixture(options: NativeSkillsStrongh if (!game.strongholdCards) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: true }); if (options.optionalTech && !game.techTokens) game = applyAction(game, game.host, { type: 'techTokens', enabled: true }); if (game.techTokens) assert.ok(game.players.length >= 3); - const initial = structuredClone(game), actions: StrongholdFactionsNativeStep[] = [], staging: string[] = []; + const initial = structuredClone(game), actions: StrongholdFactionsNativeStep[] = [], + staging: string[] = ['Scoped original scalar action entropy keeps native source programs reproducible; UUID byte arrays remain native.']; const native: AdvancedNativeSkillsOptions = { family, skillOwner: ownerFaction, requestedSkill: 'suk-graduate', initial: game }; game = initializeAdvancedNativeSkillsSetup(native); const offered = structuredClone(game); diff --git a/tests/fixture-paired-choam-nexus-skills-modules.ts b/tests/fixture-paired-choam-nexus-skills-modules.ts new file mode 100644 index 00000000..02a412d7 --- /dev/null +++ b/tests/fixture-paired-choam-nexus-skills-modules.ts @@ -0,0 +1,239 @@ +import assert from 'node:assert/strict'; +import { applyAction, createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine'; +import { territory } from '../game/board'; +import { strongholdControllers } from '../game/stronghold-cards'; +import { + advancePairedChoamSkills, advancePairedChoamSkillsToPhase, allowPairedChoamSkills, + assertPairedChoamSkillsCustody, completePairedChoamSkillsSetup, initializePairedChoamNexusSkills, + nextPairedChoamSkillsStep, pairedSkillsClean, pairedSkillsPlayer, pairedSkillsSnapshot, + placePairedSkillsForce, revealPairedSkillsRichese, stepPairedChoamSkills, + type PairedChoamNexusSkillsFixture, type PairedChoamNexusSkillsOptions, type PairedChoamNexusSkillsStep, +} from './fixture-paired-choam-nexus-skills'; + +/** Source composition, not a new ruling: + * docs/NEXUS_CARD_RULES.md, "Paired E1/E2 Nexus and Leader Skills composition" + * (signed Richese pair and original CHOAM Special fuel), + * docs/LEADER_SKILLS_RUNTIME.md, "Paired E1/E2 Nexus composition" and + * "Advanced Stronghold composition"; original module custody/timing in + * fixture-paired-choam-nexus-modules.ts and fixture-native-skills-stronghold.ts. + * No borrowed Smuggler, pair companion, mixed No-Field plan or preview is added. + */ +export type PairedChoamNexusSkillsModulesOptions = PairedChoamNexusSkillsOptions & { + tech?: boolean; + strongholds?: boolean; + /** Conserved pre-END-Mentat occupant; the engine, never this fixture, earns custody. */ + arrakeenOwner?: 'richese' | 'choam' | 'guild'; +}; +export type PairedChoamNexusSkillsModulesFixture = PairedChoamNexusSkillsFixture & { + tech: boolean; + strongholds: boolean; +}; +export type PairedChoamNexusSkillsModulesStep = PairedChoamNexusSkillsStep; + +// Keep the original legal native policies and human plan/posture boundaries. +export { + nextPairedChoamSkillsStep as nextPairedChoamSkillsModulesStep, + stepPairedChoamSkills as stepPairedChoamSkillsModules, + advancePairedChoamSkills as advancePairedChoamSkillsModules, + advancePairedChoamSkillsToPhase as advancePairedChoamSkillsModulesToPhase, + completePairedChoamSkillsSetup as completePairedChoamSkillsModulesSetup, + allowPairedChoamSkills as allowPairedChoamSkillsModules, + revealPairedSkillsRichese as revealPairedModulesRichese, + openPairedSkillsBattle as openPairedModulesSkillsBattle, + pairedSkillsBattlePlans as pairedModulesSkillsBattlePlans, + finishPairedSkillsBattle as finishPairedModulesSkillsBattle, + quotePairedSkillsBattle as quotePairedModulesSkillsBattle, + pairedSkillsRicheseCunningRequest as pairedModulesSkillsRicheseCunningRequest, + pairedSkillsChoamCunningRequest as pairedModulesSkillsChoamCunningRequest, + stagePairedSkillsPlanetologistRoute as stagePairedModulesSkillsPlanetologistRoute, + holdPairedSkillsCard as holdPairedModulesSkillsCard, + placePairedSkillsForce as placePairedModulesSkillsForce, + pairedSkillsClean as pairedModulesSkillsClean, + pairedSkillsPlayer as pairedModulesSkillsPlayer, + pairedSkillsSnapshot as pairedModulesSkillsSnapshot, + assertPairedChoamSkillsCustody as assertPairedChoamSkillsModulesCustody, +} from './fixture-paired-choam-nexus-skills'; + +/** Lobby options use real module actions before the original all14 initializer. + * Supplied setup offers, first-dealt hands and authenticated IDs are authoritative. + * An existing module selection is retained unless an explicit lobby option changes it. + */ +export function initializePairedChoamNexusSkillsModules(options: PairedChoamNexusSkillsModulesOptions = {}): Game { + const advanced = options.advanced ?? options.initial?.advanced ?? true; + const factions = options.factions ?? ['richese', 'choam', 'guild', 'atreides'] as const; + let game = options.initial ? pairedSkillsSnapshot(options.initial) + : createGame('PAIREDCHOAMNEXUSSKILLSMODULES', newPlayer(options.seatIds?.[0] ?? factions[0], factions[0], factions[0]), advanced, ['choam']); + if (!options.initial) for (const faction of factions.slice(1)) + joinGame(game, newPlayer(options.seatIds?.[game.players.length] ?? faction, faction, faction)); + const tech = options.tech ?? (options.initial ? !!game.techTokens : true); + const strongholds = options.strongholds ?? (options.initial ? !!game.strongholdCards : advanced); + assert.ok(tech || strongholds, 'Select original Tech and/or Stronghold Cards'); + assert.equal(game.advanced, advanced); + assert.ok(!strongholds || advanced, 'Original Stronghold Cards require Advanced'); + assert.ok(game.players.length >= (tech ? 3 : 2) && game.players.length <= 6); + if (game.status === 'lobby') { + if (!!game.techTokens !== tech) game = applyAction(game, game.host, { type: 'techTokens', enabled: tech }); + if (!!game.strongholdCards !== strongholds) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: strongholds }); + } else { + assert.equal(!!game.techTokens, tech, 'A saved setup cannot change its original Tech selection'); + assert.equal(!!game.strongholdCards, strongholds, 'A saved setup cannot change its original Stronghold selection'); + } + return initializePairedChoamNexusSkills({ ...options, initial: game, advanced }); +} + +/** Labeled rule-position control: every original counter stays in native custody. */ +function reserveBoard(game: Game, staging: string[]): void { + 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 = {}; + } + if (p.advisors) p.advisors = {}; + } + staging.push('Conserved original board counters into their own reserves; no wallet, phase, turn, module owner, skill or earned receipt is assigned.'); +} +function orderSpice(game: Game, kinds: ('worm' | 'land')[], staging: string[]): void { + const front = kinds.map(kind => { + const index = game.spiceDeck.findIndex(card => kind === 'worm' ? 'worm' in card : 'territory' in card); + assert.ok(index >= 0, 'Only genuine still-undealt Spice Cards can be ordered'); + return game.spiceDeck.splice(index, 1)[0]; + }); + game.spiceDeck.unshift(...front); + staging.push(`Conserved original undealt Spice order: ${kinds.join(', ')}; played discards are unchanged.`); +} + +/** Actual native starts/skill-first setup, first Storm, previous zero-marker, + * END Mentat, worm, reciprocal classic alliance and closing Cunning draws. + * Four seats are necessary for BOTH unallied draws; a two-seat SH game can use + * initialize/complete/step/advance exports but never receives fabricated Nexus. + */ +export function createPairedChoamNexusSkillsModulesFixture(options: PairedChoamNexusSkillsModulesOptions = {}): PairedChoamNexusSkillsModulesFixture { + const actions: PairedChoamNexusSkillsStep[] = [], staging: string[] = []; + let game = initializePairedChoamNexusSkillsModules(options); + const initial = pairedSkillsSnapshot(game); + game = completePairedChoamSkillsSetup(game, options, actions); + const afterSetup = pairedSkillsSnapshot(game); + const richese = game.players.find(p => p.faction === 'richese')!.id; + const choam = game.players.find(p => p.faction === 'choam')!.id; + const guild = game.players.find(p => p.faction === 'guild')?.id; + const observer = game.players.find(p => p.faction !== 'richese' && p.faction !== 'choam' && p.faction !== 'guild')?.id; + assert.ok(guild && observer, 'Both own Cunning draws require two other real alliance seats including native Guild'); + const location = `habbanya_ridge_sietch:${territory('habbanya_ridge_sietch').sectors[0]}`; + game = advancePairedChoamSkillsToPhase(game, 1, actions); + const afterFirstStorm = pairedSkillsSnapshot(game); + orderSpice(game, ['land', 'land'], staging); + game = advancePairedChoamSkills(game, g => g.phase === 5 && g.active === richese && pairedSkillsClean(g), actions); + reserveBoard(game, staging); + const before = pairedSkillsSnapshot(game), p = pairedSkillsPlayer(game, richese); + const shipmentStep: PairedChoamNexusSkillsStep = { actor: richese, action: { type: 'ship', + noField: p.noField!.tokens.find(t => t.value === 0)!.id, event: p.noFieldEvent, + territory: 'habbanya_ridge_sietch', sector: territory('habbanya_ridge_sietch').sectors[0] } }; + game = stepPairedChoamSkills(game, shipmentStep, actions); + game = allowPairedChoamSkills(game, actions); + const firstShipment = { before, step: shipmentStep, after: pairedSkillsSnapshot(game) }; + game = revealPairedSkillsRichese(game, richese, actions); + game = advancePairedChoamSkillsToPhase(game, 8, actions); + reserveBoard(game, staging); + placePairedSkillsForce(game, richese, location, 1, staging); + const actors: Record<'richese' | 'choam' | 'guild', string> = { richese, choam, guild }; + placePairedSkillsForce(game, actors[options.arrakeenOwner ?? 'choam'], `arrakeen:${territory('arrakeen').sectors[0]}`, 1, staging); + let beforeFirstMentat: Game | undefined, firstMentatStep: PairedChoamNexusSkillsStep | undefined; + for (let count = 0; game.turn === 1 && count < 200; count++) { + const previous = pairedSkillsSnapshot(game), next = nextPairedChoamSkillsStep(game); + assert.ok(next); + game = stepPairedChoamSkills(game, next, actions); + if (game.turn === 2) { beforeFirstMentat = previous; firstMentatStep = next; } + } + assert.ok(beforeFirstMentat && firstMentatStep); + const afterFirstMentat = pairedSkillsSnapshot(game); + game = advancePairedChoamSkillsToPhase(game, 1, actions); + orderSpice(game, ['worm', 'land', 'land'], staging); + game = advancePairedChoamSkills(game, g => g.nexus === true && !g.spiceWindow && !g.spiceResolution && pairedSkillsClean(g), actions); + const beforeAlliance = pairedSkillsSnapshot(game), allianceActions: PairedChoamNexusSkillsStep[] = [ + { actor: guild, action: { type: 'alliance', target: observer } }, + { actor: observer, action: { type: 'alliance', target: guild } }, + ]; + for (const next of allianceActions) game = stepPairedChoamSkills(game, next, actions); + game = advancePairedChoamSkills(game, g => g.nexusCards?.phase?.stage === 'drawing' && pairedSkillsClean(g), actions); + const beforeClosingDraw = pairedSkillsSnapshot(game), closingDraws: PairedChoamNexusSkillsFixture['closingDraws'] = []; + for (const [actor, card] of [[richese, 'richese'], [choam, 'choam']] as const) { + const cards = game.nexusCards!.cards!, index = cards.deck.indexOf(card); + assert.ok(index >= 0); + cards.deck.unshift(cards.deck.splice(index, 1)[0]); + staging.push(`Conserved unused original ${card} Nexus singleton placed first before its actual closing draw.`); + const drawBefore = pairedSkillsSnapshot(game); + const next: PairedChoamNexusSkillsStep = { actor, action: { type: 'nexusCardChoice', turn: game.turn, card: null, choice: 'draw', ownRedraws: 0 } }; + game = stepPairedChoamSkills(game, next, actions); + closingDraws.push({ before: drawBefore, step: next, after: pairedSkillsSnapshot(game) }); + } + game = advancePairedChoamSkillsToPhase(game, 5, actions); + reserveBoard(game, staging); + const movementStart = pairedSkillsSnapshot(game); + game = advancePairedChoamSkills(game, g => g.phase === 5 && g.active === richese && pairedSkillsClean(g), actions); + if (options.reserveCap !== undefined) { + const p = pairedSkillsPlayer(game, richese); + assert.ok(Number.isSafeInteger(options.reserveCap) && options.reserveCap >= 0 && options.reserveCap <= p.reserves); + placePairedSkillsForce(game, richese, 'polar_sink:0', p.reserves - options.reserveCap, staging); + } + assertPairedChoamSkillsCustody(game); + const trainers = { richese: game.leaderSkills!.assignments.find(a => a.owner === richese)!.leader, + choam: game.leaderSkills!.assignments.find(a => a.owner === choam)!.leader }; + return { initial, afterSetup, afterFirstStorm, firstShipment, beforeFirstMentat, firstMentatStep, afterFirstMentat, + beforeAlliance, allianceActions, beforeClosingDraw, closingDraws, movementStart, game, richese, choam, guild, observer, + location, trainers, actions, staging, tech: !!game.techTokens, strongholds: !!game.strongholdCards }; +} + +/** Preserve the original response boundary before the actual Guild allowance. */ +export function advancePairedModulesRicheseToGuild(game: Game, actions?: PairedChoamNexusSkillsStep[]): Game { + return advancePairedChoamSkills(game, g => g.decision?.kind === 'guildShipment' || pairedSkillsClean(g), actions); +} +export function pairedModulesRicheseGuildStep(game: Game, allow = true): PairedChoamNexusSkillsStep { + const decision = game.decision; + assert.ok(decision?.kind === 'guildShipment', 'Use the actual native Guild interception decision'); + return { actor: decision.player, action: { type: 'decision', allow } }; +} +export function advancePairedModulesBattleToDecision(game: Game, kind: 'sukRescue' | 'battleCards' | 'techToken', actions?: PairedChoamNexusSkillsStep[]): Game { + return advancePairedChoamSkills(game, g => g.decision?.kind === kind || !g.battle && pairedSkillsClean(g), actions); +} +export function finishPairedChoamSkillsModulesMovement(game: Game, actions?: PairedChoamNexusSkillsStep[]): Game { + return advancePairedChoamSkillsToPhase(game, 6, actions); +} +/** Current physical control is not held-card custody; only real END Mentat updates owners. */ +export function pairedModulesSkillsStrongholdControls(game: Game) { + return strongholdControllers(game.players, false); +} +/** Native human declaration stays an Action, not a fixture-invented proof or frame. */ +export function declarePairedModulesSkills(game: Game, actor: string, action: Action, actions?: PairedChoamNexusSkillsStep[]): Game { + return stepPairedChoamSkills(game, { actor, action }, actions); +} + +/** Stop before a real human visibility choice; do not silently choose its band. */ +export function openPairedModulesSkillsBattlePosture(game: Game, owner: string, opponent: string, + options: { territory?: string; actions?: PairedChoamNexusSkillsStep[] } = {}): Game { + game = advancePairedChoamSkillsToPhase(game, 6, options.actions); + assert.equal(game.phase, 6, 'The real ordinary battle requires conserved opposing forces'); + assert.ok(game.active === owner || game.active === opponent); + game = stepPairedChoamSkills(game, { actor: game.active!, action: { type: 'chooseBattle', + territory: options.territory ?? 'habbanya_ridge_sietch', target: game.active === owner ? opponent : owner } }, options.actions); + return advancePairedChoamSkills(game, g => g.decision?.kind === 'leaderSkillVisibility' || + pairedSkillsClean(g) && !!g.battle && (!g.battle.preLeader || g.battle.preLeader.closed) && !g.battle.preparation, options.actions); +} +/** Continue actual preparation after the caller's human posture action. */ +export function advancePairedModulesSkillsToPlans(game: Game, actions?: PairedChoamNexusSkillsStep[]): Game { + assert.ok(game.battle && !game.battle.revealed, 'Continue the current ordinary unsealed battle'); + return advancePairedChoamSkills(game, g => pairedSkillsClean(g) && !!g.battle && + (!g.battle.preLeader || g.battle.preLeader.closed) && !g.battle.preparation, actions); +} +/** The original two plans reveal before traitor calls, physical losses or rewards. */ +export function revealPairedModulesSkillsBattle(game: Game, plans: readonly PairedChoamNexusSkillsStep[], + actions?: PairedChoamNexusSkillsStep[]): Game { + for (const plan of plans) { + assert.equal(plan.action.type, 'battlePlan'); + game = stepPairedChoamSkills(game, plan, actions); + game = allowPairedChoamSkills(game, actions); + } + assert.ok(game.battle?.revealed, 'Both original plans must actually reveal'); + return game; +} diff --git a/tests/fixture-paired-ix-nexus-skills-modules.ts b/tests/fixture-paired-ix-nexus-skills-modules.ts new file mode 100644 index 00000000..1f7ff995 --- /dev/null +++ b/tests/fixture-paired-ix-nexus-skills-modules.ts @@ -0,0 +1,459 @@ +import assert from 'node:assert/strict'; +import { + applyAction, createGame, handLimit, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, + viewGame, type Action, type Game, type Player, +} from '../game/engine'; +import { MOBILE_LOCATION, MOBILE_STRONGHOLD, territory } from '../game/board'; +import type { Card } from '../game/cards'; +import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards'; +import { pairedNexusLeaderSkillsProfile } from '../game/leader-skill-profile'; +import { quoteBattleResolution, type ResolutionCombatant } from '../game/battle-resolution-quote'; +import { ownedTech } from '../game/tech-tokens'; +import { strongholdControllers, type StrongholdId } from '../game/stronghold-cards'; +import { traitorDeck } from '../game/traitors'; +import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions'; + +export type PairedIxNexusSkillsModulesStep = { actor: string; action: Action }; +export type PairedIxNexusSkillsModulesOptions = { + /** Genuine fresh lobby or original turn-one/phase-zero setup with zero assignments. + * Existing first hands, offers, actor IDs and stock are authoritative. */ + initial?: Game; + advanced?: boolean; + tech?: boolean; + strongholds?: boolean; + mobileBattle?: boolean; + program?: 'suboids' | 'faceDance'; + skill?: LeaderSkillId; + band?: 'normal' | 'skilled'; + dial?: number; + support?: number; +}; +export type PairedIxNexusSkillsModulesSourceFacts = { + sources: readonly string[]; + staging: string[]; + turn: number; + phase: number; + cardOwners: Record | null; + controllers: Record; + tech: Game['techTokens'] | null; + stock: { player: string; faceDancers: NonNullable }[]; + physicalForces: { player: string; reserves: number; tanks: number; forces: Record; elites: Player['elites'] }[]; +}; +export type PairedIxNexusSkillsModulesFixture = { + initial: Game; + offered: Game; + afterSetup: Game; + afterFirstStorm: Game; + beforeFirstMentat: Game; + firstMentatStep: PairedIxNexusSkillsModulesStep; + afterFirstMentat: Game; + beforeAlliance: Game; + allianceActions: PairedIxNexusSkillsModulesStep[]; + beforeNexusDraw: Game; + drawActions: PairedIxNexusSkillsModulesStep[]; + afterNexusDraw: Game; + beforeBattle: Game; + battleStep: PairedIxNexusSkillsModulesStep; + game: Game; + ixians: string; + tleilaxu: string; + opponent: string; + partners: [string, string]; + trainer: string; + skill: LeaderSkillId; + band: 'normal' | 'skilled'; + program: 'suboids' | 'faceDance'; + kind: 'arrakeen' | 'sietch_tabr' | typeof MOBILE_STRONGHOLD; + sourceFacts: PairedIxNexusSkillsModulesSourceFacts; + location: string; + replacementSource: string; + planActions: PairedIxNexusSkillsModulesStep[]; + actions: PairedIxNexusSkillsModulesStep[]; + staging: string[]; +}; +export function pairedIxNexusSkillsModulesPlayer(game: Game, actor: string): Player { + const player = game.players.find(p => p.id === actor); + assert.ok(player, `Missing original E1 seat ${actor}`); + return player; +} +const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision; + +/** Control native shuffles only, never fabricate dealt cards. Non-word entropy + * forwards through the bound original method and always returns its input array. */ +function entropy(operation: () => T, rolls: number[] = []): T { + const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); + const original = crypto.getRandomValues.bind(crypto); + let cursor = 0; + crypto.getRandomValues = (array: V): V => { + if (array instanceof Uint32Array) { + for (let i = 0; i < array.length; i++) array[i] = rolls[cursor++] ?? 0xffffffff; + } else Reflect.apply(original, crypto, [array]); + return array; + }; + try { return operation(); } + finally { + if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); + else Reflect.deleteProperty(crypto, 'getRandomValues'); + } +} +function shuffleRolls(source: string[], desired: string[]): number[] { + const working = [...source], rolls: number[] = []; + assert.deepEqual([...source].sort(), [...desired].sort()); + for (let i = working.length - 1; i > 0; i--) { + const from = working.indexOf(desired[i]); assert.ok(from >= 0 && from <= i); + rolls.push(Math.floor((from + 0.5) * 0x100000000 / (i + 1))); + [working[i], working[from]] = [working[from], working[i]]; + } + return rolls; +} +function skillRolls(selected: LeaderSkillId[]): number[] { + const source = LEADER_SKILL_CARDS.map(c => c.id); + const remainder = source.filter(c => !selected.includes(c)); + const desired = selected.flatMap(c => [c, remainder.shift()!]); + return shuffleRolls(source, [...desired, ...remainder]); +} +/** Original default continuation declines native Face Dance; callers can stop + * at its real window before choosing a matching-stock reveal. Private plans remain explicit. */ +export function nextPairedIxNexusSkillsModulesStep(game: Game): PairedIxNexusSkillsModulesStep | null { + if (clean(game) && game.nexusCards?.phase?.stage === 'drawing') { + const phase = game.nexusCards.phase, actor = phase.eligible.find(id => !phase.done.includes(id)); + assert.ok(actor); + return { actor, action: { type: 'nexusCardChoice', turn: game.turn, + card: game.nexusCards.cards!.hands[actor] ?? null, choice: 'keep', ownRedraws: 0 } }; + } + if (!game.phaseOpening && !game.response) { + const d = game.decision; + if (d?.kind === 'techToken') return { actor: d.player, action: { type: 'decision', token: d.choices[0] } }; + if (d?.kind === 'strongholdCopy') return null; + if (d?.kind === 'faceDance') return { actor: d.player, action: { type: 'decision', reveal: false } }; + if (d?.kind === 'leaderSkillVisibility') return { actor: d.player, + action: { type: 'leaderSkillVisibility', event: d.event, hide: false } }; + if (d?.kind === 'mentatQuestion') return { actor: d.player, + action: { type: 'decision', event: d.event, decline: true } }; + if (d?.kind === 'rihani') return { actor: d.player, + action: { type: 'decision', event: d.event, draw: false } }; + if (d?.kind === 'sukRescue') { + const best = d.options.map((o, choice) => ({ choice, count: o.normal + o.elite, kept: !!o.kept })) + .sort((a, b) => b.count - a.count || Number(b.kept) - Number(a.kept))[0]; + return { actor: d.player, action: { type: 'decision', event: d.event, choice: best.choice } }; + } + } + return nextStrongholdFactionsNativeStep(game); +} +export function stepPairedIxNexusSkillsModules(game: Game, step = nextPairedIxNexusSkillsModulesStep(game)): Game { + assert.ok(step, 'Original human plan or matching-stock Face Dance is required'); + return entropy(() => applyAction(game, step.actor, step.action)); +} +export function advancePairedIxNexusSkillsModules(state: Game, until: (game: Game) => boolean, + actions: PairedIxNexusSkillsModulesStep[] = []): Game { + let game = structuredClone(state); + for (let i = 0; i < 2400; i++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished'); + const step = nextPairedIxNexusSkillsModulesStep(game); assert.ok(step, 'Native explicit human choice before requested boundary'); + actions.push(structuredClone(step)); game = stepPairedIxNexusSkillsModules(game, step); + } + throw Error('Paired E1 Nexus/Skills continuation did not reach its native boundary'); +} +function reserveBoard(game: Game): 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 = {}; + } +} +function place(game: Game, actor: string, key: string, total: number, elite = 0): void { + const p = pairedIxNexusSkillsModulesPlayer(game, actor); + assert.ok(total >= elite && p.reserves - (p.elites?.reserves ?? 0) >= total - elite && (p.elites?.reserves ?? 0) >= elite); + p.reserves -= total; p.forces[key] = (p.forces[key] ?? 0) + total; + if (elite) { p.elites!.reserves -= elite; p.elites!.forces[key] = (p.elites!.forces[key] ?? 0) + elite; } +} +function hold(game: Game, actor: string, kind: Card['kind'], staging: string[]): string { + const p = pairedIxNexusSkillsModulesPlayer(game, actor), held = p.hand.find(c => c.kind === kind); + if (held) return held.id; + const index = game.deck.findIndex(c => c.kind === kind); + const donor = index < 0 ? game.players.find(other => other.id !== actor && other.hand.some(c => c.kind === kind)) : undefined; + assert.ok(index >= 0 || donor, 'Required original physical card has a deck/hand custodian'); + assert.ok(p.hand.length < handLimit(p), 'Preserve actual starting hands and require real capacity'); + const card = index >= 0 ? game.deck.splice(index, 1)[0] : donor!.hand.splice(donor!.hand.findIndex(c => c.kind === kind), 1)[0]; + p.hand.push(card); staging.push(`Conserved ${card.id}: ${donor?.id ?? 'original remaining deck'} → ${actor} hand after native deals`); + return card.id; +} +function orderSpice(game: Game, kinds: ('land' | 'worm')[], staging: string[]): void { + const remaining = [...game.spiceDeck]; + const ordered = kinds.map(kind => { + const at = remaining.findIndex(c => kind === 'land' ? 'territory' in c : 'worm' in c && !c.greatMaker && !c.suppressed); + assert.ok(at >= 0); return remaining.splice(at, 1)[0]; + }); + game.spiceDeck = [...ordered, ...remaining]; + staging.push(`Conserved original undealt Spice order: ${kinds.join(', ')}; not natural shuffle history`); +} +/** Printed composition sources: docs/NEXUS_CARD_RULES.md, paired E1/E2 + * Skills and Tech/Stronghold sections; docs/LEADER_SKILLS_RUNTIME.md, paired + * Nexus, Advanced Stronghold and native Tech sections. This adds no revival ruling. */ +export const PAIRED_IX_NEXUS_SKILLS_MODULES_SOURCES = [ + 'docs/NEXUS_CARD_RULES.md#paired-e1e2-nexus-and-leader-skills-composition--4-october-2026', + 'docs/NEXUS_CARD_RULES.md#paired-e1e2-tech-and-stronghold-composition--4-october-2026', + 'docs/LEADER_SKILLS_RUNTIME.md#advanced-stronghold-composition--4-october-2026', + 'docs/LEADER_SKILLS_RUNTIME.md#native-e1e2-tech-tokens-composition--4-october-2026', +] as const; +/** Two-seat Stronghold-only native setup is legal, but cannot manufacture an + * unallied closing draw. The battle program below separately requires classics. */ +export function initializePairedIxNexusSkillsModulesSetup(options: PairedIxNexusSkillsModulesOptions = {}) { + const advanced = options.advanced ?? options.initial?.advanced ?? true; + const tech = options.tech ?? (options.initial ? !!options.initial.techTokens : true); + const strongholds = options.strongholds ?? (options.initial ? !!options.initial.strongholdCards : advanced); + assert.ok(tech || strongholds); assert.ok(!strongholds || advanced); + let game = options.initial ? structuredClone(options.initial) + : createGame('PAIREDIXNEXUSSKILLSMODULES', newPlayer('ixians', 'Ixians', 'ixians'), advanced, ['ix']); + if (!options.initial) for (const faction of ['tleilaxu', 'guild', 'emperor'] as const) + joinGame(game, newPlayer(faction, faction, faction)); + assert.equal(game.advanced, advanced); assert.deepEqual(game.expansions, ['ix']); + assert.ok(!game.homeworlds); + assert.ok(game.players.length >= (tech ? 3 : 2) && game.players.length <= 6); + const ixians = game.players.find(p => p.faction === 'ixians')?.id; + const tleilaxu = game.players.find(p => p.faction === 'tleilaxu')?.id; + assert.ok(ixians && tleilaxu); + const classics = game.players.filter(p => p.id !== ixians && p.id !== tleilaxu); + assert.ok(classics.every(p => ['atreides', 'harkonnen', 'emperor', 'guild', 'fremen', 'beneGesserit'].includes(p.faction))); + const actions: PairedIxNexusSkillsModulesStep[] = [], staging: string[] = []; + const requested = options.skill ?? 'suk-graduate'; + const passives: LeaderSkillId[] = (['sandmaster', 'smuggler', 'rihani-decipherer', 'prana-bindu-adept', 'killer-medic', 'master-of-assassins'] as LeaderSkillId[]) + .filter(skill => skill !== requested); + const selected = game.players.map(p => p.id === ixians ? requested : passives.shift()!); + const supplied = !!options.initial; + const initial = structuredClone(game); + if (game.status === 'lobby') { + assert.ok(!game.leaderSkills, 'Original all14 custody cannot be replaced'); + for (const [type, enabled, selectedModule] of [ + ['techTokens', tech, !!game.techTokens], ['strongholdCards', strongholds, !!game.strongholdCards], + ] as const) if (enabled !== selectedModule) { + const step: PairedIxNexusSkillsModulesStep = { actor: game.host, action: { type, enabled } }; + actions.push(step); game = stepPairedIxNexusSkillsModules(game, step); + } + game.nexusCards ??= { cards: null, phase: null }; + assert.deepEqual(game.nexusCards, { cards: null, phase: null }); + for (const p of game.players) if (!p.ready) { + const step: PairedIxNexusSkillsModulesStep = { actor: p.id, action: { type: 'ready' } }; + actions.push(step); game = stepPairedIxNexusSkillsModules(game, step); + } + game = entropy(() => initializeLeaderSkillsGameForAudit(game), skillRolls(selected)); + staging.push('Scoped original all14 shuffle selects native offers before starting Treachery deals; no offers are replaced'); + } + assert.ok(pairedNexusLeaderSkillsProfile(game)); + assert.equal(!!game.techTokens, tech); assert.equal(!!game.strongholdCards, strongholds); + assert.equal(game.status, 'setup'); assert.equal(game.turn, 1); assert.equal(game.phase, 0); + assert.equal(game.leaderSkills!.assignments.length, 0); + let offered = structuredClone(game), trainer = ''; + for (let i = 0; game.status === 'setup' && i < 240; i++) { + let step: PairedIxNexusSkillsModulesStep; + if (game.setupStage === 'leaderSkills') { + offered = trainer ? offered : structuredClone(game); + const actor: string = Object.keys(game.leaderSkills!.offers)[0]; + const offer: NonNullable['offers'][string] = game.leaderSkills!.offers[actor]; + const projection = viewGame(game, actor).leaderSkills!; + const legal: LeaderSkillId[] = offer.cards.filter(c => !projection.unavailableSkills?.[c]); + const desired: LeaderSkillId = selected[game.players.findIndex(p => p.id === actor)]; + const skill: LeaderSkillId | undefined = actor === ixians && options.skill ? legal.find(c => c === options.skill) + : !supplied && legal.includes(desired) ? desired : legal[0]; + assert.ok(skill, 'Explicit requested skill must genuinely exist in the original saved offer'); + const eligible = new Set(projection.eligibleLeaders.map(l => l.id)); + const leader = pairedIxNexusSkillsModulesPlayer(game, actor).leaders.filter(l => eligible.has(l.id)).sort((a, b) => b.strength - a.strength)[0]; + assert.ok(leader); + if (actor === ixians) trainer = leader.id; + step = { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: leader.id } }; + } else { + const native = nextPairedIxNexusSkillsModulesStep(game); assert.ok(native); step = native; + if (!supplied && game.setupStage === 'traitors' && step.action.type === 'traitor') { + const choices = pairedIxNexusSkillsModulesPlayer(game, step.actor).traitorChoices; + const card = choices.find(id => id !== trainer); assert.ok(card); step.action.leader = card; + } + } + actions.push(structuredClone(step)); + if (!supplied && game.setupStage === 'forces') { + const held = new Set(game.players.flatMap(p => p.traitors)); + const source = traitorDeck(game.players, true).filter(id => !held.has(id)); + assert.ok(source.includes(trainer), 'Original trained Ix identity must remain undealt'); + staging.push('Scoped original remaining Traitor shuffle orders the undealt trainer before the actual native Face Dancer draw; no matching stock is manufactured or exchanged'); + game = entropy(() => applyAction(game, step.actor, step.action), shuffleRolls(source, [trainer, ...source.filter(id => id !== trainer)])); + } else game = supplied ? applyAction(game, step.actor, step.action) : stepPairedIxNexusSkillsModules(game, step); + } + assert.equal(game.status, 'playing'); + const afterSetup = structuredClone(game); + const assignment = game.leaderSkills!.assignments.find(a => a.owner === ixians)!; + trainer = assignment.leader; const skill = assignment.skill; + return { initial, offered, afterSetup, game, ixians, tleilaxu, trainer, skill, actions, staging }; +} +/** Actual initializer → original skill/Traitor/faction setup → Storm → END + * Mentat → qualifying alliance → closing draw. Only conserved order/counters/ + * cards are staged; no wallet, ownership, hand face or earned receipt is assigned. */ +export function createPairedIxNexusSkillsModulesFixture(options: PairedIxNexusSkillsModulesOptions = {}): PairedIxNexusSkillsModulesFixture { + const setup = initializePairedIxNexusSkillsModulesSetup(options); + const { initial, offered, afterSetup, ixians, tleilaxu, trainer, skill, actions, staging } = setup; + let game = setup.game; + const advanced = game.advanced, program = options.program ?? 'suboids', band = options.band ?? 'skilled'; + const classics = game.players.filter(p => p.id !== ixians && p.id !== tleilaxu); + assert.ok(classics.length >= 2, 'Two classics must form a real alliance; two-seat setup does not authorize a fabricated closing draw'); + const partners: [string, string] = [classics[0].id, classics[1].id]; + game = advancePairedIxNexusSkillsModules(game, g => g.phase === 1 && clean(g), actions); + const afterFirstStorm = structuredClone(game); + orderSpice(game, ['land', 'land'], staging); + game = advancePairedIxNexusSkillsModules(game, g => g.phase === 5 && clean(g), actions); + reserveBoard(game); staging.push('After native starting placements: conserved board counters returned to original reserves before first movement completion'); + game = advancePairedIxNexusSkillsModules(game, g => g.phase === 8 && clean(g), actions); + if (game.strongholdCards) { + reserveBoard(game); + place(game, ixians, `arrakeen:${territory('arrakeen').sectors[0]}`, 1); + place(game, ixians, program === 'faceDance' ? `sietch_tabr:${territory('sietch_tabr').sectors[0]}` : MOBILE_LOCATION, 1); + staging.push('Before actual END Mentat: conserved Ix counters control only Arrakeen and Tabr/HMS; held Cards must be earned, not assigned'); + } + let beforeFirstMentat!: Game, firstMentatStep!: PairedIxNexusSkillsModulesStep; + while (game.turn === 1) { + const step = nextPairedIxNexusSkillsModulesStep(game); assert.ok(step); + const before = structuredClone(game); actions.push(structuredClone(step)); game = stepPairedIxNexusSkillsModules(game, step); + if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = step; } + } + const afterFirstMentat = structuredClone(game); + game = advancePairedIxNexusSkillsModules(game, g => g.phase === 1 && clean(g), actions); + orderSpice(game, ['worm', 'land', 'land'], staging); + game = advancePairedIxNexusSkillsModules(game, g => !!g.nexus && !g.spiceWindow && !g.spiceResolution && clean(g), actions); + const beforeAlliance = structuredClone(game); + const allianceActions: PairedIxNexusSkillsModulesStep[] = partners.map((actor, i) => ({ actor, action: { type: 'alliance', target: partners[1 - i] } })); + for (const step of allianceActions) { actions.push(step); game = stepPairedIxNexusSkillsModules(game, step); } + game = advancePairedIxNexusSkillsModules(game, g => g.nexusCards?.phase?.stage === 'drawing', actions); + const cards = game.nexusCards!.cards!; + assert.ok(cards.deck.includes('ixians') && cards.deck.includes('tleilaxu')); + cards.deck = ['ixians', 'tleilaxu', ...cards.deck.filter(c => c !== 'ixians' && c !== 'tleilaxu')]; + staging.push('Order conserved unused Ix/Tleilaxu Nexus singletons; only real closing draws grant the two native hands'); + const beforeNexusDraw = structuredClone(game); + const drawActions: PairedIxNexusSkillsModulesStep[] = [ixians, tleilaxu].map(actor => ({ actor, + action: { type: 'nexusCardChoice', turn: game.turn, card: cards.hands[actor], choice: 'draw', ownRedraws: 0 } })); + for (const step of drawActions) { actions.push(step); game = stepPairedIxNexusSkillsModules(game, step); } + game = advancePairedIxNexusSkillsModules(game, g => g.nexusCards?.phase?.stage !== 'drawing', actions); + const afterNexusDraw = structuredClone(game); + game = advancePairedIxNexusSkillsModules(game, g => g.phase === 5 && clean(g), actions); + reserveBoard(game); + const opponent = game.techTokens ? classics.find(p => ownedTech(game.techTokens, p.id).length)?.id + : classics.find(p => p.faction === 'guild')?.id ?? classics[0].id; + assert.ok(opponent, 'The real first Storm must allocate a classic opponent token'); + assert.ok(!options.mobileBattle || game.strongholdCards && program === 'suboids'); + const kind = options.mobileBattle ? MOBILE_STRONGHOLD : program === 'faceDance' ? 'sietch_tabr' : 'arrakeen'; + const location = kind === MOBILE_STRONGHOLD ? MOBILE_LOCATION : `${kind}:${territory(kind).sectors[0]}`; + const replacementSource = `arrakeen:${territory('arrakeen').sectors[0]}`; + place(game, ixians, location, 6, 3); place(game, opponent, location, 6); + if (program === 'faceDance') place(game, tleilaxu, replacementSource, 3); + if (options.mobileBattle) { + place(game, ixians, replacementSource, 1); + place(game, ixians, `sietch_tabr:${territory('sietch_tabr').sectors[0]}`, 1); + staging.push('Turn-two controlled HMS copy sources: Arrakeen and Tabr counters after actual two-hold Mentat; no later three-hold Mentat victory is suppressed'); + } + staging.push('Before native movement completion: conserved six Ix counters (three Cyborg/three Suboid), six ordinary opponents and optional physical replacement sources; no phase, wallet or stock mutation'); + const ix = pairedIxNexusSkillsModulesPlayer(game, ixians), stock = pairedIxNexusSkillsModulesPlayer(game, tleilaxu).faceDancers!; + const untrained = (p: Player) => p.leaders.filter(l => !l.dead && !l.usedAt && !l.gholaBy && !l.capturedBy && + !game.leaderSkills!.assignments.some(a => a.leader === l.id)); + const leader = band === 'skilled' ? ix.leaders.find(l => l.id === trainer)! : untrained(ix) + .filter(l => program !== 'faceDance' || stock.some(c => c.leader === l.id && !c.revealed)).sort((a, b) => b.strength - a.strength)[0]; + assert.ok(leader && (program !== 'faceDance' || stock.some(c => c.leader === leader.id && !c.revealed)), + 'Infeasible original stock: this band requires a matching living original Ix disc; no stock is fabricated or exchanged'); + const enemy = untrained(pairedIxNexusSkillsModulesPlayer(game, opponent)).sort((a, b) => a.strength - b.strength)[0]; assert.ok(enemy); + const weapon = skill === 'warmaster' ? hold(game, ixians, 'worthless', staging) + : program === 'faceDance' ? hold(game, ixians, 'projectile', staging) : null; + const defense = program === 'faceDance' ? hold(game, ixians, 'shield', staging) : null; + game = advancePairedIxNexusSkillsModules(game, g => g.phase === 6 && clean(g), actions); + const beforeBattle = structuredClone(game), actor = game.active!; assert.ok(actor === ixians || actor === opponent); + const battleStep: PairedIxNexusSkillsModulesStep = { actor, action: { type: 'chooseBattle', territory: kind, target: actor === ixians ? opponent : ixians } }; + actions.push(battleStep); game = stepPairedIxNexusSkillsModules(game, battleStep); + const planActions: PairedIxNexusSkillsModulesStep[] = [ + { actor: ixians, action: { type: 'battlePlan', leader: leader.id, dial: options.dial ?? (advanced ? 4 : 6), + support: advanced ? options.support ?? 1 : 0, weapon, defense } }, + { actor: opponent, action: { type: 'battlePlan', leader: enemy.id, dial: 0, support: 0, weapon: null, defense: null } }, + ]; + return { initial, offered, afterSetup, afterFirstStorm, beforeFirstMentat, firstMentatStep, afterFirstMentat, + beforeAlliance, allianceActions, beforeNexusDraw, drawActions, afterNexusDraw, beforeBattle, battleStep, + game, ixians, tleilaxu, opponent, partners, trainer, skill, band, program, kind, location, replacementSource, + planActions, actions, staging, sourceFacts: pairedIxNexusSkillsModulesSourceFacts(game, staging) }; +} +export function preparePairedIxNexusSkillsModulesBattle(f: PairedIxNexusSkillsModulesFixture, state = f.game): Game { + let game = structuredClone(state); + for (let i = 0; i < 160; i++) { + const step = nextPairedIxNexusSkillsModulesStep(game); + if (!step) { + if (game.decision?.kind === 'strongholdCopy') { + assert.ok(game.decision.choices.includes('arrakeen')); + game = stepPairedIxNexusSkillsModules(game, { actor: game.decision.player, action: { type: 'decision', event: game.decision.event, stronghold: 'arrakeen' } }); + continue; + } + assert.ok(game.battle && !game.battle.revealed); return game; + } + if (step.action.type === 'leaderSkillVisibility') step.action.hide = step.actor === f.ixians && f.band === 'skilled'; + game = stepPairedIxNexusSkillsModules(game, step); + } + throw Error('Paired E1 native skill posture did not reach private plans'); +} +export function beginPairedIxNexusSkillsModulesCunning(f: PairedIxNexusSkillsModulesFixture, state = preparePairedIxNexusSkillsModulesBattle(f)): Game { + const offer = viewGame(state, f.ixians).nexusSuboids?.offer; + assert.ok(offer); assert.equal(offer.blocked, null); + return stepPairedIxNexusSkillsModules(state, { actor: f.ixians, action: { type: 'nexusSuboids', event: offer.event } }); +} +export function revealPairedIxNexusSkillsModulesBattle(f: PairedIxNexusSkillsModulesFixture, + options: { state?: Game; cunning?: boolean; plans?: PairedIxNexusSkillsModulesStep[] } = {}): Game { + let game = preparePairedIxNexusSkillsModulesBattle(f, options.state ?? f.game); + if (options.cunning) game = advancePairedIxNexusSkillsModules(beginPairedIxNexusSkillsModulesCunning(f, game), clean); + for (const step of options.plans ?? f.planActions) { + game = stepPairedIxNexusSkillsModules(game, step); + while (game.response || game.phaseOpening || game.decision?.kind === 'fullPlanOffer') game = stepPairedIxNexusSkillsModules(game); + } + assert.ok(game.battle?.revealed); return game; +} +export type PairedIxNexusSkillsModulesBoundary = 'losses' | 'rescue' | 'substitution' | 'cards' | 'tech' | 'faceDance' | 'complete'; +/** `tech` stops at a real mandatory chooser if one exists. A singleton reward + * transfers automatically, so its observable boundary is the following actual + * Face Dance (or completed aftermath), never a manufactured extra decision. */ +export function finishPairedIxNexusSkillsModulesBattle(state: Game, + stop: PairedIxNexusSkillsModulesBoundary = 'complete'): Game { + const kinds: Record = { + losses: 'battleLosses', rescue: 'sukRescue', substitution: 'ixSubstitution', + cards: 'battleCards', tech: 'techToken', faceDance: 'faceDance', complete: '', + }; + return advancePairedIxNexusSkillsModules(state, g => + g.decision?.kind === kinds[stop] || + stop === 'tech' && g.decision?.kind === 'faceDance' && !g.pendingTech || + !g.battle && clean(g) && !g.pendingTreacheryDiscard); +} +export function finishPairedIxNexusSkillsModulesTurn(state: Game): Game { + return advancePairedIxNexusSkillsModules(state, g => g.turn > state.turn); +} +/** Actual skill posture and actual native Cunning force flags, not inferred cap arithmetic. */ +export function quotePairedIxNexusSkillsModulesBattle(game: Game) { + const battle = game.battle; assert.ok(battle?.revealed); + const side = (actor: string): ResolutionCombatant => { + const p = pairedIxNexusSkillsModulesPlayer(game, actor), view = viewGame(game, actor).battle!; assert.ok(view.ownForces); + return { id: actor, faction: p.faction, ally: p.ally, spice: p.spice, hand: p.hand, plan: battle.plans[actor], + leader: p.leaders.find(l => l.id === battle.plans[actor].leader), forces: view.ownForces, + stronghold: view.strongholdEffects[actor], leaderSkills: game.leaderSkills!.assignments.filter(a => a.owner === actor) + .map(a => ({ skill: a.skill, leader: a.leader, faceUp: !battle.leaderSkillHidden?.[actor], captured: false })) }; + }; + return quoteBattleResolution({ advanced: game.advanced, turn: game.turn, territory: battle.territory, + aggressor: viewGame(game, game.host).battle!.aggressor, attacker: side(battle.attacker), defender: side(battle.defender), + voters: viewGame(game, game.host).battle!.traitorVoters.map(actor => ({ id: actor, + beneficiary: [battle.attacker, battle.defender].includes(actor) ? actor : pairedIxNexusSkillsModulesPlayer(game, actor).ally!, + called: battle.traitorCalls[actor] ?? false, traitors: pairedIxNexusSkillsModulesPlayer(game, actor).traitors })), + participants: game.players, physicalCards: [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand)], + pendingAuditorPresent: false, pendingRetentionPresent: false }); +} + +/** Source facts are observations, never prescribed ownership/effects. */ +export function pairedIxNexusSkillsModulesSourceFacts(game: Game, staging: string[] = []): PairedIxNexusSkillsModulesSourceFacts { + return { + sources: PAIRED_IX_NEXUS_SKILLS_MODULES_SOURCES, + staging: [...staging], turn: game.turn, phase: game.phase, + cardOwners: game.strongholdCards ? { ...game.strongholdCards.owners } : null, + controllers: strongholdControllers(game.players, !!game.mobileStronghold?.location), + tech: game.techTokens ? structuredClone(game.techTokens) : null, + stock: game.players.map(p => ({ player: p.id, faceDancers: structuredClone(p.faceDancers ?? []) })), + physicalForces: game.players.map(p => ({ player: p.id, reserves: p.reserves, tanks: p.tanks, + forces: { ...p.forces }, elites: structuredClone(p.elites) })), + }; +} diff --git a/tests/paired-choam-nexus-skills-modules-runtime.test.ts b/tests/paired-choam-nexus-skills-modules-runtime.test.ts new file mode 100644 index 00000000..e4fded3a --- /dev/null +++ b/tests/paired-choam-nexus-skills-modules-runtime.test.ts @@ -0,0 +1,456 @@ +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 { splitLocation, territory } from '../game/board'; +import { presenceAt } from '../game/force-presence'; +import { ownedTech } from '../game/tech-tokens'; +import { quotePhaseResources } from '../game/phase-resource-quote'; +import { quoteSpiceCollection } from '../game/board-resolution-quote'; +import { reserveShipmentCost } from '../game/shipment-price'; +import { isPlanetologistBattleSpecialCard } from '../game/leader-skill-combat'; +import { + advancePairedChoamSkillsModules as advance, advancePairedChoamSkillsModulesToPhase as toPhase, + advancePairedModulesBattleToDecision, advancePairedModulesRicheseToGuild, + allowPairedChoamSkillsModules as allow, assertPairedChoamSkillsModulesCustody as custody, + completePairedChoamSkillsModulesSetup, createPairedChoamNexusSkillsModulesFixture as fixture, + finishPairedChoamSkillsModulesMovement, finishPairedModulesSkillsBattle as finishBattle, + holdPairedModulesSkillsCard as hold, initializePairedChoamNexusSkillsModules, + openPairedModulesSkillsBattle as openBattle, pairedModulesRicheseGuildStep, + pairedModulesSkillsBattlePlans as plans, pairedModulesSkillsChoamCunningRequest as choamRequest, + pairedModulesSkillsClean as clean, pairedModulesSkillsPlayer as player, + pairedModulesSkillsRicheseCunningRequest as richeseRequest, pairedModulesSkillsSnapshot as snapshot, + pairedModulesSkillsStrongholdControls as controls, placePairedModulesSkillsForce as place, + quotePairedModulesSkillsBattle as quoteBattle, revealPairedModulesRichese as reveal, + stagePairedModulesSkillsPlanetologistRoute as stageRoute, stepPairedChoamSkillsModules as step, + type PairedChoamNexusSkillsModulesFixture, +} from './fixture-paired-choam-nexus-skills-modules'; + +const combinations = [ + { advanced: false, tech: true, strongholds: false }, + { advanced: true, tech: true, strongholds: false }, + { advanced: true, tech: false, strongholds: true }, + { advanced: true, tech: true, strongholds: true }, +] as const; + +function reject(game: Game, actor: string, action: Action): void { + const before = snapshot(game); + assert.throws(() => applyAction(game, actor, action)); + assert.deepEqual(game, before, 'Rejected actions cannot consume a native invoice, physical card, marker, force, skill or reward'); +} +function assertPair(f: PairedChoamNexusSkillsModulesFixture, shipped: Game, faceup: number): void { + const request = richeseRequest(f), before = player(f.game, f.richese), after = player(shipped, f.richese); + assert.equal(after.spice, before.spice - 1); + assert.equal(player(shipped, f.guild).spice, player(f.game, f.guild).spice + 1); + assert.equal(after.reserves, before.reserves - faceup); + assert.equal(after.forces[f.location] ?? 0, faceup); + assert.equal(after.noField!.deployed!.tokenId, request.noField); + assert.equal(after.noField!.lastShipped, request.noField); + assert.equal(presenceAt(after, 'habbanya_ridge_sietch'), faceup + 1); + assert.equal(shipped.nexusCards!.cards!.hands[f.richese], null); + assert.equal(shipped.nexusRicheseCunningLast!.stage, 'shipped'); + assert.deepEqual(shipped.leaderSkills!.assignments, f.game.leaderSkills!.assignments); + assert.deepEqual(after.noField!.tokens, before.noField!.tokens); + custody(shipped); +} +/** Collection is an independent later receipt, never a battle subsidy. */ +function assertBattleWallets(before: Game, after: Game): void { + const quote = quoteBattle(before); + const collection = after.phase === 7 && clean(after) + ? quoteSpiceCollection({ ...after, players: after.players.map(p => ({ ...p, spice: 0 })) }).receipts : []; + for (const p of before.players) { + const payment = quote.payments.find(row => row.player === p.id)?.ownPayment ?? 0; + const choam = quote.choamIncome?.owner === p.id ? quote.choamIncome.amount : 0; + const stronghold = quote.strongholdIncome.filter(row => row.player === p.id).reduce((sum, row) => sum + row.amount, 0); + const bounty = quote.bounty?.player === p.id ? quote.bounty.amount : 0; + const city = collection.find(row => row.player === p.id); + assert.equal(player(after, p.id).spice, p.spice - payment + choam + stronghold + bounty + (city?.strongholds ?? 0) + (city?.collected ?? 0), p.faction); + } +} + +for (const modules of combinations) void test(`paired Skills ${modules.advanced ? 'Advanced' : 'Basic'} Tech=${modules.tech} SH=${modules.strongholds}: original signed pair, one native invoice and once-only phase-end industry`, () => { + const f = fixture(modules), previous = player(f.game, f.richese).noField!.tokens.find(token => token.value === 0)!; + assert.equal(player(f.firstShipment.after, f.richese).spice, player(f.firstShipment.before, f.richese).spice - 1); + assert.equal(player(f.firstShipment.after, f.richese).reserves, player(f.firstShipment.before, f.richese).reserves); + assert.equal(player(f.game, f.richese).noField!.lastShipped, previous.id); + assert.equal(f.beforeAlliance.nexus, true); + assert.equal(player(f.beforeClosingDraw, f.guild).ally, f.observer); + assert.equal(player(f.beforeClosingDraw, f.observer).ally, f.guild); + for (const draw of f.closingDraws) { + assert.equal(draw.before.nexusCards!.cards!.hands[draw.step.actor], null); + assert.equal(draw.after.nexusCards!.cards!.hands[draw.step.actor], player(draw.after, draw.step.actor).faction); + } + if (modules.tech) { + assert.ok(Object.values(f.afterSetup.techTokens!).every(token => token.owner === null)); + assert.ok(Object.values(f.afterFirstStorm.techTokens!).every(token => token.owner && f.afterFirstStorm.players.some(p => p.id === token.owner))); + } + if (modules.strongholds) { + assert.equal(f.beforeFirstMentat.strongholdCards!.owners.habbanya_ridge_sietch, null); + assert.equal(f.beforeFirstMentat.strongholdCards!.owners.arrakeen, null); + assert.equal(f.afterFirstMentat.strongholdCards!.owners.habbanya_ridge_sietch, f.richese); + assert.equal(f.afterFirstMentat.strongholdCards!.owners.arrakeen, f.choam); + assert.equal(controls(f.game).habbanya_ridge_sietch, null); + assert.equal(f.game.strongholdCards!.owners.habbanya_ridge_sietch, f.richese, 'Leaving does not transfer a held physical card before END Mentat'); + } + const request = richeseRequest(f); + reject(f.game, f.richese, { ...request, noField: previous.id }); + reject(f.game, f.richese, { ...request, revealedToken: previous.id }); + reject(f.game, f.richese, { ...request, revealedToken: request.noField }); + reject(f.game, f.richese, { ...request, nexus: 'stale-pair' }); + reject(f.game, f.richese, { ...request, smuggler: true }); + reject(f.game, f.richese, { ...request, amount: 8 }); + hold(f.game, f.observer, card => card.effect === 'karama', f.staging); + const declared = applyAction(f.game, f.richese, request); + assert.equal(declared.response?.kind, 'richeseNoField'); + assert.equal(declared.pendingShipment!.cost, 1); + assert.equal(declared.pendingShipment!.amount, 1); + assert.equal(declared.pendingShipment!.noFieldSkillProof, undefined); + assert.equal(declared.response!.noFieldSkillProof, undefined); + assert.equal(declared.pendingShipment!.smugglerCompanion, undefined); + const receipt = declared.nexusRicheseCunningHistory!.at(-1)!; + assert.equal(receipt.frame, JSON.stringify(declared.pendingShipment), 'The original two-marker frame remains signed without an ordinary single-marker skill proof'); + assert.equal(receipt.signature, JSON.stringify([receipt.event, receipt.owner, receipt.turn, receipt.phase, receipt.tokenEvent, receipt.frame, receipt.stage])); + assert.equal(player(declared, f.richese).spice, player(f.game, f.richese).spice); + assert.deepEqual(player(declared, f.richese).forces, player(f.game, f.richese).forces); + // Deliberately corrupt a saved invoice, not a legal staging grant. + const corrupted = snapshot(declared); + corrupted.pendingShipment!.cost = 0; + const responder = corrupted.players.find(p => !corrupted.response!.passed.includes(p.id))!.id; + reject(corrupted, responder, { type: 'passResponse' }); + let shipped = advancePairedModulesRicheseToGuild(declared); + if (modules.advanced) { + assert.equal(shipped.decision?.kind, 'guildShipment'); + assert.equal(shipped.pendingShipment!.cost, 1); + assert.equal(player(shipped, f.richese).spice, player(f.game, f.richese).spice); + reject(shipped, f.guild, { type: 'decision', allow: false }); + shipped = allow(step(shipped, pairedModulesRicheseGuildStep(shipped))); + } + assertPair(f, shipped, 5); + reject(shipped, f.richese, request); + assert.equal(player(allow(shipped), f.guild).spice, player(shipped, f.guild).spice, 'Response continuation cannot repeat the original fee'); + if (modules.tech) { + const token = shipped.techTokens!.heighliners; + assert.ok(token.owner); + const amount = ownedTech(f.game.techTokens, token.owner).length; + assert.equal(token.spice, amount); + assert.equal(token.triggeredTurn, shipped.turn); + assert.equal(player(shipped, token.owner).spice, player(f.game, token.owner).spice + (token.owner === f.richese ? -1 : token.owner === f.guild ? 1 : 0)); + let another = shipped; + if (shipped.movementRemaining?.includes(f.choam)) { + const atChoam = advance(shipped, g => g.phase === 5 && g.active === f.choam && clean(g)); + another = allow(applyAction(atChoam, f.choam, { type: 'ship', amount: 1, territory: 'arrakeen', sector: territory('arrakeen').sectors[0] })); + assert.equal(player(another, f.choam).spice, player(atChoam, f.choam).spice - 1); + assert.equal(player(another, f.guild).spice, player(atChoam, f.guild).spice + 1); + } + assert.equal(another.techTokens!.heighliners.spice, amount); + const credit = quotePhaseResources(another).credits.find(row => row.kind === 'tech' && row.token === 'heighliners'); + assert.ok(credit); + const collection = quoteSpiceCollection(another).receipts.find(row => row.player === token.owner); + const ended = finishPairedChoamSkillsModulesMovement(another); + assert.equal(player(ended, token.owner).spice, credit.balance + (ended.phase === 7 ? (collection?.collected ?? 0) + (collection?.strongholds ?? 0) : 0)); + assert.equal(ended.techTokens!.heighliners.spice, 0); + assert.equal(ended.techTokens!.heighliners.triggeredTurn, ended.turn); + custody(ended); + } +}); + +for (const modules of combinations) for (const trained of [false, true]) + void test(`paired Skills ${modules.advanced ? 'Advanced' : 'Basic'} Tech=${modules.tech} SH=${modules.strongholds}: voluntary pair reveal, ${trained ? 'skilled' : 'normal'} Suk, native support and winner Tech`, () => { + const f = fixture(modules); + let game = allow(applyAction(f.game, f.richese, richeseRequest(f))); + game = reveal(game, f.richese); + assert.equal(player(game, f.richese).noField!.deployed, null); + assert.equal(player(game, f.richese).forces[f.location], 8); + assert.equal(player(game, f.richese).reserves, 12); + place(game, f.guild, f.location, 3, f.staging); + place(game, f.choam, `arrakeen:${territory('arrakeen').sectors[0]}`, 1, f.staging); + game = openBattle(game, f.richese, f.guild, { band: trained ? 'skilled' : 'normal' }); + const battlePlans = plans(game, f.richese, f.guild, 2, trained); + assert.equal(battlePlans[0].action.leader === f.trainers.richese, trained); + for (const next of battlePlans) game = allow(step(game, next)); + assert.ok(game.battle?.revealed); + const revealed = snapshot(game), quote = quoteBattle(game), loserTokens = ownedTech(game.techTokens, f.guild); + assert.equal(quote.winner, f.richese); + assert.equal(quote.result, 'normal'); + assert.equal(quote.sukGraduate!.mode, trained ? 'skilled' : 'normal'); + assert.equal(viewGame(game, f.richese).battle!.ownForces!.normal, 8); + assert.equal(quote.payments.find(row => row.player === f.richese)?.ownPayment ?? 0, modules.advanced ? 2 : 0); + assert.equal(quote.payments.find(row => row.player === f.richese)?.bankSupport ?? 0, 0); + assert.deepEqual(quote.choamIncome, modules.advanced ? { owner: f.choam, amount: 1 } : null); + if (trained) { + game = advancePairedModulesBattleToDecision(game, 'sukRescue'); + assert.equal(game.decision?.kind, 'sukRescue'); + if (game.decision?.kind !== 'sukRescue') throw Error('The real skilled casualty commitment must reach Suk'); + const pending = snapshot(game), decision = game.decision; + reject(game, f.richese, { type: 'decision', event: 'stale-suk', choice: 0 }); + assert.deepEqual(game, pending); + if (modules.tech && modules.strongholds) for (const difficulty of DIFFICULTIES) { + const view = viewGame(pending, f.richese); + view.players.find(p => p.id === f.richese)!.bot = difficulty; + const action = botActions(view)[0]; + assert.ok(action?.type === 'decision' && typeof action.choice === 'number'); + const rescue = decision.options[action.choice]; + assert.ok(rescue); + const resolved = finishBattle(applyAction(pending, f.richese, action)); + const kept = rescue.kept?.kind === 'normal' ? 1 : 0; + assert.equal(player(resolved, f.richese).forces[f.location], 6 + kept, difficulty); + assert.equal(player(resolved, f.richese).reserves, 12 + rescue.normal - kept, difficulty); + assert.equal(player(resolved, f.richese).tanks, 2 - rescue.normal, difficulty); + assert.equal(player(resolved, f.guild).tanks, 3, difficulty); + for (const token of loserTokens) assert.equal(resolved.techTokens![token].owner, f.richese, difficulty); + assertBattleWallets(revealed, resolved); custody(resolved); + } + if (modules.tech) for (const token of loserTokens) + assert.equal(game.techTokens![token].owner, f.guild, 'Original loser Tech cannot transfer before physical Suk rescue settles'); + const choice = decision.options.findIndex(option => option.normal === 2 && option.elite === 0 && option.kept?.kind === 'normal'); + assert.ok(choice >= 0); + game = applyAction(game, f.richese, { type: 'decision', event: decision.event, choice }); + } + game = finishBattle(game); + assert.equal(game.lastBattleContext!.winner, f.richese); + assert.equal(player(game, f.richese).forces[f.location], trained ? 7 : 6); + assert.equal(player(game, f.richese).reserves, 13); + assert.equal(player(game, f.richese).tanks, trained ? 0 : 1); + assert.equal(player(game, f.guild).tanks, 3); + if (modules.tech) { + assert.ok(loserTokens.length > 0, 'Use original first-Storm loser custody, not a granted token'); + for (const token of loserTokens) assert.equal(game.techTokens![token].owner, f.richese); + } + if (modules.strongholds) assert.equal(game.strongholdCards!.owners.habbanya_ridge_sietch, f.richese); + assertBattleWallets(revealed, game); + custody(game); + }); + +for (const modules of combinations) void test(`paired Tech=${modules.tech} SH=${modules.strongholds} ${modules.advanced ? 'Advanced' : 'Basic'}: reserve cap and voluntary reveal preserve actual previous-token restriction`, () => { + const f = fixture({ ...modules, reserveCap: 2 }); + const shipped = allow(applyAction(f.game, f.richese, richeseRequest(f))); + assertPair(f, shipped, 2); + const revealed = reveal(shipped, f.richese), previous = player(revealed, f.richese).noField!.lastShipped!; + assert.equal(player(revealed, f.richese).noField!.deployed, null); + assert.equal(player(revealed, f.richese).forces[f.location], 2); + assert.equal(player(revealed, f.richese).reserves, 0); + let next = advance(revealed, g => g.turn === 3 && g.phase === 1 && clean(g)); + const land = next.spiceDeck.filter(card => 'territory' in card).slice(0, 2); + assert.equal(land.length, 2); + for (const [index, card] of land.entries()) next.spiceDeck.splice(index, 0, next.spiceDeck.splice(next.spiceDeck.indexOf(card), 1)[0]); + f.staging.push('Conserved two unused original land cards before turn-three Spice; no played discard is restored.'); + next = advance(next, g => g.turn === 3 && g.phase === 5 && g.active === f.richese && clean(g)); + reject(next, f.richese, { type: 'ship', noField: previous, event: player(next, f.richese).noFieldEvent, territory: 'polar_sink', sector: 0 }); + const zero = player(next, f.richese).noField!.tokens.find(token => token.value === 0)!; + const accepted = allow(applyAction(next, f.richese, { type: 'ship', noField: zero.id, event: player(next, f.richese).noFieldEvent, territory: 'polar_sink', sector: 0 })); + assert.equal(player(accepted, f.richese).noField!.deployed!.tokenId, zero.id); + assert.equal(player(accepted, f.richese).noField!.lastShipped, zero.id); + assert.equal(player(accepted, f.richese).spice, player(next, f.richese).spice - 2); + custody(accepted); +}); + +void test('paired Skills/Tech/SH: native Karama stops only the pair; unpaid replacement cannot consume money, counters, markers or industry', () => { + const f = fixture(), karama = hold(f.game, f.guild, card => card.kind === 'special' && card.effect === 'karama', f.staging); + const before = snapshot(f.game), request = richeseRequest(f); + const pending = applyAction(f.game, f.richese, request); + const stopped = allow(applyAction(pending, f.guild, { type: 'card', mode: 'cancel', card: karama.id })); + assert.equal(stopped.nexusRicheseCunningLast!.stage, 'stopped'); + assert.equal(stopped.nexusCards!.cards!.hands[f.richese], null); + for (const p of before.players) assert.equal(player(stopped, p.id).spice, p.spice); + assert.deepEqual(player(stopped, f.richese).noField, player(before, f.richese).noField); + assert.deepEqual(player(stopped, f.richese).forces, player(before, f.richese).forces); + assert.equal(player(stopped, f.richese).reserves, player(before, f.richese).reserves); + assert.deepEqual(stopped.techTokens, before.techTokens); + assert.equal(player(stopped, f.richese).shipped, false); + assert.equal(stopped.discard.filter(card => card.id === karama.id).length, 1); + reject(stopped, f.richese, request); + const ordinary = allow(applyAction(stopped, f.richese, { type: 'ship', amount: 1, territory: 'habbanya_ridge_sietch', sector: territory('habbanya_ridge_sietch').sectors[0] })); + assert.equal(player(ordinary, f.richese).spice, player(stopped, f.richese).spice - 1); + assert.equal(player(ordinary, f.guild).spice, player(stopped, f.guild).spice + 1); + assert.equal(ordinary.techTokens!.heighliners.triggeredTurn, ordinary.turn); + const fresh = fixture(), amount = player(fresh.game, fresh.richese).spice; + assert.ok(amount > 0); + const unpaid = applyAction(fresh.game, fresh.richese, { type: 'bribe', target: fresh.guild, amount }); + assert.equal(player(unpaid, fresh.richese).spice, 0); + reject(unpaid, fresh.richese, richeseRequest(fresh, unpaid)); + assert.equal(unpaid.nexusCards!.cards!.hands[fresh.richese], 'richese'); + custody(stopped); custody(ordinary); custody(unpaid); +}); + +for (const modules of combinations) void test(`paired Skills ${modules.advanced ? 'Advanced' : 'Basic'} Tech=${modules.tech} SH=${modules.strongholds}: actual CHOAM Special fuel precedes a separate ordinary Planetologist move`, () => { + const f = fixture(modules); + let game = advance(f.movementStart, g => g.phase === 5 && g.active === f.choam && clean(g)); + const fuel = hold(game, f.choam, isPlanetologistBattleSpecialCard, f.staging); + hold(game, f.guild, card => card.effect === 'karama' && card.id !== fuel.id, f.staging); + const route = stageRoute(game, f.choam, f.staging), request = choamRequest(game, f.choam, fuel.id), before = snapshot(game); + reject(before, f.choam, route.action); + reject(before, f.choam, { ...request, nexus: 'stale-physical-fuel' }); + game = applyAction(game, f.choam, request); + assert.equal(game.response?.kind, 'choamWorthless'); + assert.equal(game.nexusCards!.cards!.hands[f.choam], null); + assert.deepEqual(player(game, f.choam).hand.find(card => card.id === fuel.id), fuel); + game = allow(game); + assert.equal(game.nexusChoamLast!.stage, 'complete'); + assert.equal(game.discard.filter(card => card.id === fuel.id).length, 1); + assert.equal(player(game, f.choam).hand.some(card => card.id === fuel.id), false); + assert.equal(game.choamMovement!.bonus, 1); + reject(game, f.choam, request); + reject(game, f.choam, { ...route.action, noField: 'not-ordinary-counters' }); + const moved = allow(applyAction(game, f.choam, route.action)); + assert.equal(player(moved, f.choam).forces[route.from] ?? 0, 0); + assert.equal(player(moved, f.choam).forces[route.to], 1); + assert.equal(player(moved, f.choam).moved, 1); + assert.equal(moved.discard.filter(card => card.id === fuel.id).length, 1, 'Moving does not replay the already spent printed Special'); + assert.deepEqual(moved.leaderSkills!.assignments, before.leaderSkills!.assignments); + for (const p of before.players) assert.equal(player(moved, p.id).spice, p.spice); + custody(moved); + if (modules.tech && modules.strongholds) { + // Conserved ordinary counter source for a later native battle; never revive fuel. + place(moved, f.choam, route.to, 3, f.staging); + place(moved, f.guild, route.to, 3, f.staging); + let battle = openBattle(moved, f.choam, f.guild, { territory: splitLocation(route.to).territory, band: 'skilled' }); + const battlePlans = plans(battle, f.choam, f.guild, 1, true); + reject(battle, f.choam, { ...battlePlans[0].action, weapon: fuel.id }); + for (const next of battlePlans) battle = allow(step(battle, next)); + const revealed = snapshot(battle), quote = quoteBattle(battle); + const bonus = battle.battle!.attacker === f.choam ? quote.leaderSkillBonuses.attacker : quote.leaderSkillBonuses.defender; + assert.equal(bonus.applied.some(applied => applied.skill === 'planetologist'), false, 'Spent Kulon fuel cannot become an artificial Special-card battle bonus'); + assert.equal(quote.winner, f.choam); + const finished = finishBattle(battle); + assert.equal(player(finished, f.choam).forces[route.to], 3); + assert.equal(player(finished, f.choam).tanks, 1); + assert.equal(player(finished, f.guild).tanks, 3); + assert.equal(finished.discard.filter(card => card.id === fuel.id).length, 1); + assertBattleWallets(revealed, finished); custody(finished); + } +}); + +void test('paired Skills/Tech/SH: canceled CHOAM Cunning retains its real Special and grants neither Kulon range nor a replay', () => { + const f = fixture(); + let game = advance(f.movementStart, g => g.phase === 5 && g.active === f.choam && clean(g)); + const fuel = hold(game, f.choam, isPlanetologistBattleSpecialCard, f.staging); + const karama = hold(game, f.guild, card => card.kind === 'special' && card.effect === 'karama' && card.id !== fuel.id, f.staging); + const route = stageRoute(game, f.choam, f.staging), before = snapshot(game), request = choamRequest(game, f.choam, fuel.id); + game = applyAction(game, f.choam, request); + game = allow(applyAction(game, f.guild, { type: 'card', mode: 'cancel', card: karama.id })); + assert.equal(game.nexusChoamLast!.stage, 'canceled'); + assert.equal(game.nexusCards!.cards!.hands[f.choam], null); + assert.deepEqual(player(game, f.choam).hand.find(card => card.id === fuel.id), fuel); + assert.equal(game.discard.some(card => card.id === fuel.id), false); + assert.equal(game.discard.filter(card => card.id === karama.id).length, 1); + assert.equal(game.choamMovement?.bonus ?? 0, 0); + reject(game, f.choam, route.action); + reject(game, f.choam, request); + for (const p of before.players) assert.equal(player(game, p.id).spice, p.spice); + custody(game); +}); + +void test('paired ordinary Suk in retained Arrakeen uses original bank support while CHOAM still earns declared-support income', () => { + const f = fixture({ arrakeenOwner: 'richese' }); + assert.equal(f.beforeFirstMentat.strongholdCards!.owners.arrakeen, null); + assert.equal(controls(f.beforeFirstMentat).arrakeen, f.richese); + assert.equal(f.afterFirstMentat.strongholdCards!.owners.arrakeen, f.richese); + let game = reveal(allow(applyAction(f.game, f.richese, richeseRequest(f))), f.richese); + const location = `arrakeen:${territory('arrakeen').sectors[0]}`, richese = player(game, f.richese); + // Conserved alternate ordinary battle position, not a claimed played move. + const group = richese.forces[f.location]; + delete richese.forces[f.location]; + richese.forces[location] = group; + f.staging.push(`Conserved ${group} revealed Richese counters: ${f.location} → ${location}; not native movement history.`); + place(game, f.guild, location, 3, f.staging); + assert.equal(controls(game).arrakeen, null, 'Contested current control is not a transfer of the held physical Arrakeen card'); + assert.equal(game.strongholdCards!.owners.arrakeen, f.richese); + game = openBattle(game, f.richese, f.guild, { territory: 'arrakeen', band: 'skilled' }); + for (const next of plans(game, f.richese, f.guild, 2, true)) game = allow(step(game, next)); + const revealed = snapshot(game), quote = quoteBattle(game), payment = quote.payments.find(row => row.player === f.richese)!; + assert.equal(viewGame(game, f.richese).battle!.strongholdEffects[f.richese], 'arrakeen'); + assert.equal(payment.ownPayment, 0); + assert.equal(payment.bankSupport, 2); + assert.deepEqual(quote.choamIncome, { owner: f.choam, amount: 1 }, 'Native income counts original declared support, including the actual bank-funded leg'); + assert.equal(quote.sukGraduate!.mode, 'skilled'); + game = finishBattle(game); + assert.equal(player(game, f.richese).forces[location], 7); + assert.equal(player(game, f.richese).reserves, 13); + assert.equal(player(game, f.richese).tanks, 0); + assert.equal(player(game, f.guild).tanks, 3); + assert.equal(game.strongholdCards!.owners.arrakeen, f.richese); + assertBattleWallets(revealed, game); custody(game); +}); + +void test('paired retained Habbanya wins a tied ordinary battle only after actual reveal; mixed marker plans remain excluded', () => { + const f = fixture(); + let game = allow(applyAction(f.game, f.richese, richeseRequest(f))); + place(game, f.guild, f.location, 6, f.staging); + const beforeMovementEnd = snapshot(game); + game = finishPairedChoamSkillsModulesMovement(game); + assert.equal(game.phase, 6); + assert.ok(game.active === f.richese || game.active === f.guild); + const beforeMixed = snapshot(game); + assert.throws(() => applyAction(game, game.active!, { type: 'chooseBattle', territory: 'habbanya_ridge_sietch', + target: game.active === f.richese ? f.guild : f.richese }), /Mixed ordinary-force and No-Field/); + assert.deepEqual(game, beforeMixed); + game = reveal(beforeMovementEnd, f.richese); + game = openBattle(game, f.richese, f.guild, { band: 'skilled' }); + const r = player(game, f.richese), g = player(game, f.guild); + const rLeader = r.leaders.filter(leader => !leader.dead && leader.id !== f.trainers.richese).sort((a, b) => b.strength - a.strength)[0]; + const gTrainer = game.leaderSkills!.assignments.find(assignment => assignment.owner === f.guild)!.leader; + const gLeader = g.leaders.filter(leader => !leader.dead && leader.id !== gTrainer).sort((a, b) => b.strength - a.strength)[0]; + assert.ok(rLeader && gLeader); + const rDial = Math.max(0, gLeader.strength - rLeader.strength) + 1; + const gDial = Math.max(0, rLeader.strength - gLeader.strength) + 1; + assert.ok(rDial < 8 && gDial <= 6 && r.spice >= rDial && g.spice >= gDial); + for (const [actor, leader, dial] of [[f.richese, rLeader.id, rDial], [f.guild, gLeader.id, gDial]] as const) + game = allow(applyAction(game, actor, { type: 'battlePlan', leader, dial, support: dial, weapon: null, defense: null })); + const revealed = snapshot(game), quote = quoteBattle(game); + assert.equal(rDial + rLeader.strength, gDial + gLeader.strength); + assert.equal(quote.winner, f.richese); + assert.equal(quote.sukGraduate, undefined, 'Hidden unselected Suk does not rescue casualties'); + assert.equal(viewGame(game, f.richese).battle!.strongholdEffects[f.richese], 'habbanya_ridge_sietch'); + game = finishBattle(game); + assert.equal(player(game, f.richese).forces[f.location], 8 - rDial); + assert.equal(player(game, f.richese).tanks, rDial); + assert.equal(player(game, f.guild).tanks, 6); + assert.equal(game.strongholdCards!.owners.habbanya_ridge_sietch, f.richese); + assertBattleWallets(revealed, game); custody(game); +}); + +void test('original supplied skill offers and dealt hands survive module continuation; all four native policies pay one real pair tariff', () => { + let lobby = createGame('AUTHPAIREDSKILLSMODULES', newPlayer('auth-r', 'R', 'richese'), true, ['choam']); + joinGame(lobby, newPlayer('auth-c', 'C', 'choam')); + joinGame(lobby, newPlayer('auth-g', 'G', 'guild')); + joinGame(lobby, newPlayer('auth-a', 'A', 'atreides')); + lobby = applyAction(lobby, lobby.host, { type: 'techTokens', enabled: true }); + lobby = applyAction(lobby, lobby.host, { type: 'strongholdCards', enabled: true }); + const setup = initializePairedChoamNexusSkillsModules({ initial: lobby }), saved = snapshot(setup); + const f = fixture({ initial: setup }); + assert.deepEqual(setup, saved); + assert.deepEqual(f.initial, saved, 'Continuation cannot recreate saved offers or the first native deal'); + assert.deepEqual([f.richese, f.choam, f.guild, f.observer], ['auth-r', 'auth-c', 'auth-g', 'auth-a']); + for (const p of saved.players) assert.deepEqual(player(f.afterSetup, p.id).hand, p.hand); + const absent = setup.leaderSkills!.deck.find(skill => !setup.leaderSkills!.offers[f.richese].cards.includes(skill))!; + assert.throws(() => fixture({ initial: setup, richeseSkill: absent })); + assert.deepEqual(setup, saved); + for (const difficulty of DIFFICULTIES) { + const view = viewGame(f.game, f.richese); + view.players.find(p => p.id === f.richese)!.bot = difficulty; + const action = botActions(view)[0]; + assert.ok(action?.type === 'ship' && action.noField && action.revealedToken, difficulty); + const shipped = allow(applyAction(f.game, f.richese, action)); + assert.equal(shipped.nexusRicheseCunningLast!.stage, 'shipped', difficulty); + assert.ok(typeof action.territory === 'string'); + const tariff = reserveShipmentCost({ faction: 'richese', halfRate: false }, territory(action.territory).type, 1); + assert.equal(player(shipped, f.richese).spice, player(f.game, f.richese).spice - tariff); + assert.equal(player(shipped, f.guild).spice, player(f.game, f.guild).spice + tariff); + assert.equal(shipped.nexusCards!.cards!.hands[f.richese], null); + custody(shipped); + } +}); + +void test('two-seat original SH/Skills setup plays actual Storm without pretending an unallied closing Nexus draw is available', () => { + const setup = initializePairedChoamNexusSkillsModules({ factions: ['richese', 'choam'], tech: false, strongholds: true }); + const saved = snapshot(setup), playing = completePairedChoamSkillsModulesSetup(setup); + for (const p of saved.players) assert.deepEqual(player(playing, p.id).hand, p.hand); + const firstStorm = toPhase(playing, 1); + assert.equal(firstStorm.turn, 1); + assert.equal(firstStorm.nexusCards!.cards!.deck.length, 12); + assert.ok(Object.values(firstStorm.nexusCards!.cards!.hands).every(card => card === null)); + assert.throws(() => fixture({ initial: saved }), /Both own Cunning draws require/); + assert.deepEqual(setup, saved); + custody(firstStorm); +}); diff --git a/tests/paired-ix-nexus-skills-modules-runtime.test.ts b/tests/paired-ix-nexus-skills-modules-runtime.test.ts new file mode 100644 index 00000000..7e1104bb --- /dev/null +++ b/tests/paired-ix-nexus-skills-modules-runtime.test.ts @@ -0,0 +1,351 @@ +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 { DIFFICULTIES } from '../game/bot-profiles'; +import { ownedTech } from '../game/tech-tokens'; +import { strongholdBenefit, strongholdControllers } from '../game/stronghold-cards'; +import { botActions } from '../game/bots'; +import { territory } from '../game/board'; +import { quoteSpiceCollection } from '../game/board-resolution-quote'; +import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards'; +import { validateLeaderSkills } from '../game/leader-skills'; +import { nexusFaceDancersAction } from '../game/nexus-tleilaxu-options'; +import { + advancePairedIxNexusSkillsModules as advance, beginPairedIxNexusSkillsModulesCunning as cunning, + createPairedIxNexusSkillsModulesFixture as fixture, finishPairedIxNexusSkillsModulesBattle as finish, + finishPairedIxNexusSkillsModulesTurn as finishTurn, initializePairedIxNexusSkillsModulesSetup as setup, + pairedIxNexusSkillsModulesPlayer as player, preparePairedIxNexusSkillsModulesBattle as prepare, + quotePairedIxNexusSkillsModulesBattle as quoteBattle, revealPairedIxNexusSkillsModulesBattle as reveal, + stepPairedIxNexusSkillsModules as step, type PairedIxNexusSkillsModulesFixture, +} from './fixture-paired-ix-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 consumer actions cannot consume physical cards, forces, spice or receipts'); +} +function treachery(game: Game): string[] { + return [...game.deck, ...game.discard, ...(game.ixSetupCards ?? []), ...(game.ixAuction?.cards ?? []), + ...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? []), + ...game.players.flatMap(p => p.hand)].map(c => c.id).sort(); +} +function custody(game: Game, original: Game): void { + assert.equal(treachery(game).length, 47); + assert.deepEqual(treachery(game), treachery(original)); + assert.equal(new Set(treachery(game)).size, 47); + validateLeaderSkills(game.leaderSkills!, game.players); + const skills = [...game.leaderSkills!.deck, ...Object.values(game.leaderSkills!.offers).flatMap(o => o.cards), + ...game.leaderSkills!.assignments.map(a => a.skill)].sort(); + assert.deepEqual(skills, LEADER_SKILL_CARDS.map(c => c.id).sort()); + const nexus = game.nexusCards!.cards!; + const faces = [...nexus.deck, ...nexus.discard, ...Object.values(nexus.hands).filter((c): c is NonNullable => c !== null)]; + assert.equal(faces.length, 12); assert.equal(new Set(faces).size, 12); + const stock = (g: Game) => [...(g.traitorReserve ?? []), ...g.players.flatMap(p => [...p.traitors, ...(p.faceDancers ?? []).map(c => c.leader)])].sort(); + assert.deepEqual(stock(game), stock(original)); assert.equal(new Set(stock(game)).size, stock(game).length); + for (const p of game.players) { + assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum, n) => sum + n, 0), 20, p.faction); + if (p.elites) { + const old = player(original, p.id).elites!; + assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0), + old.reserves + old.tanks + Object.values(old.forces).reduce((sum, n) => sum + n, 0)); + for (const [key, n] of Object.entries(p.elites.forces)) assert.ok(n <= (p.forces[key] ?? 0)); + } + } +} +function city(game: Game, actor: string): number { + return game.phase === 7 && !game.phaseOpening ? quoteSpiceCollection(game).receipts + .filter(r => r.player === actor).reduce((sum, r) => sum + r.collected + r.strongholds, 0) : 0; +} +function money(revealed: Game, settled: Game): void { + const quote = quoteBattle(revealed); + for (const p of revealed.players) { + const paid = quote.payments.find(r => r.player === p.id)?.ownPayment ?? 0; + const bounty = quote.bounty?.player === p.id ? quote.bounty.amount : 0; + const income = quote.strongholdIncome.filter(r => r.player === p.id).reduce((sum, r) => sum + r.amount, 0); + assert.equal(player(settled, p.id).spice, p.spice - paid + bounty + income + city(settled, p.id), p.faction); + } +} +function chooseCyborgLosses(state: Game, f: PairedIxNexusSkillsModulesFixture): Game { + const losses = finish(state, 'losses'); + assert.equal(losses.decision?.kind, 'battleLosses'); + if (losses.decision?.kind !== 'battleLosses') throw Error('Actual typed casualty choice required'); + const choice = losses.decision.options.findIndex(o => o.normal === 0 && o.elite === 3); assert.ok(choice >= 0); + assert.equal(player(losses, f.ixians).tanks, player(state, f.ixians).tanks, 'Typed commitment precedes physical Suk settlement'); + return step(losses, { actor: f.ixians, action: { type: 'decision', choice } }); +} +function dance(f: PairedIxNexusSkillsModulesFixture, pending: Game): Game { + assert.equal(pending.decision?.kind, 'faceDance'); + const count = player(pending, f.ixians).forces[f.location]; assert.ok(count >= 2); + return step(pending, { actor: f.tleilaxu, action: { type: 'decision', reveal: true, + sources: { [f.replacementSource]: 2, reserves: count - 2 }, sector: territory('sietch_tabr').sectors[0] } }); +} + +function reward(f: PairedIxNexusSkillsModulesFixture, game: Game): void { + assert.equal(game.lastBattleContext!.winner, f.ixians); + if (!f.game.techTokens) return; + const tokens = ownedTech(f.game.techTokens, f.opponent); assert.equal(tokens.length, 1); + assert.equal(game.techTokens![tokens[0]].owner, f.ixians); + assert.equal(game.techTokens![tokens[0]].spice, f.game.techTokens![tokens[0]].spice); + assert.equal(ownedTech(game.techTokens, f.opponent).length, 0); +} + +const MODES = [ + { advanced: false, tech: true, strongholds: false }, + { advanced: true, tech: true, strongholds: false }, + { advanced: true, tech: false, strongholds: true }, + { advanced: true, tech: true, strongholds: true }, +] as const; +for (const mode of MODES) for (const band of ['normal', 'skilled'] as const) { + const advanced = mode.advanced; + void test(`paired E1 Skills/modules ${JSON.stringify(mode)} ${band} Warmaster: full unsupported Suboids, real bonus and typed losses`, () => { + const f = fixture({ ...mode, band, skill: 'warmaster', dial: 3, support: 0 }); + assert.equal(f.beforeFirstMentat.turn, 1); assert.equal(f.afterFirstMentat.turn, 2); + assert.equal(f.beforeAlliance.nexus, true); + assert.equal(player(f.afterNexusDraw, f.partners[0]).ally, f.partners[1]); + assert.equal(player(f.afterNexusDraw, f.ixians).ally, null); + assert.equal(player(f.afterNexusDraw, f.tleilaxu).ally, null); + assert.equal(f.afterNexusDraw.nexusCards!.cards!.hands[f.ixians], 'ixians'); + assert.equal(f.afterNexusDraw.nexusCards!.cards!.hands[f.tleilaxu], 'tleilaxu'); + if (mode.strongholds) { + assert.equal(f.beforeFirstMentat.strongholdCards!.owners.arrakeen, null); + const claimed = step(f.beforeFirstMentat, f.firstMentatStep); + assert.equal(claimed.strongholdCards!.owners.arrakeen, f.ixians); + assert.equal(claimed.turn, 2); + } + custody(f.afterNexusDraw, f.afterSetup); + const ready = prepare(f), offer = viewGame(ready, f.ixians).nexusSuboids!.offer!; + reject(ready, f.tleilaxu, { type: 'nexusSuboids', event: offer.event }); + const active = cunning(f, ready); + assert.deepEqual(player(active, f.ixians).forces, player(ready, f.ixians).forces); + assert.deepEqual(player(active, f.ixians).elites, player(ready, f.ixians).elites); + assert.equal(player(active, f.ixians).spice, player(ready, f.ixians).spice); + assert.equal(active.nexusCards!.cards!.hands[f.ixians], null); + reject(active, f.ixians, { type: 'nexusSuboids', event: offer.event }); + const revealed = reveal(f, { state: active }); + const quote = quoteBattle(revealed); + assert.equal(quote.result, 'normal'); assert.equal(quote.winner, f.ixians); + assert.equal(quote.casualties!.forces.normalFixedHalf, false); + assert.equal(quote.casualties!.forces.normalFreeSupport, true); + const side = revealed.battle!.attacker === f.ixians ? 'attacker' : 'defender'; + assert.deepEqual(quote.leaderSkillBonuses[side].applied, [{ skill: 'warmaster', amount: band === 'skilled' ? 3 : 1, mode: band }]); + assert.equal(quote.leaderStrengths[side], player(revealed, f.ixians).leaders.find(l => l.id === f.planActions[0].action.leader)!.strength); + assert.ok(quote.casualties!.options.some(o => o.normal === 3 && o.elite === 0)); + if (advanced) { + const ordinary = quoteBattle(reveal(f)); + assert.ok(!ordinary.casualties!.options.some(o => o.normal === 3 && o.elite === 0)); + assert.equal(quote.payments.find(r => r.player === f.ixians)!.ownPayment, 0); + assert.equal(quote.payments.find(r => r.player === f.ixians)!.bankSupport, 0); + } else assert.deepEqual(quote.payments, []); + const losses = finish(revealed, 'losses'); + if (losses.decision?.kind !== 'battleLosses') throw Error('Missing actual typed winner casualties'); + const choice = losses.decision.options.findIndex(o => o.normal === 3 && o.elite === 0); assert.ok(choice >= 0); + const settled = finish(step(losses, { actor: f.ixians, action: { type: 'decision', choice } })); + assert.equal(player(settled, f.ixians).forces[f.location], 3); + assert.equal(player(settled, f.ixians).elites!.forces[f.location], 3); + assert.equal(player(settled, f.ixians).tanks, player(revealed, f.ixians).tanks + 3); + assert.equal(player(settled, f.ixians).elites!.tanks, player(revealed, f.ixians).elites!.tanks); + assert.equal(player(settled, f.opponent).tanks, player(revealed, f.opponent).tanks + 6); + money(revealed, settled); reward(f, settled); custody(settled, f.afterSetup); + }); + + void test(`paired E1 Skills/modules ${JSON.stringify(mode)} ${band} Suk: physical rescue precedes equal Suboid substitution, paid once`, () => { + const f = fixture({ ...mode, band, skill: 'suk-graduate' }); + const revealed = reveal(f, { cunning: true }), quote = quoteBattle(revealed); + assert.equal(quote.winner, f.ixians); assert.equal(quote.sukGraduate!.mode, band); + if (advanced) { + const payment = quote.payments.find(r => r.player === f.ixians)!; + assert.equal(payment.ownPayment, mode.strongholds ? 0 : 1); assert.equal(payment.bankSupport, mode.strongholds ? 1 : 0); + } else assert.deepEqual(quote.payments, []); + const original = player(revealed, f.ixians); + let selected = chooseCyborgLosses(revealed, f); + let event = '', choice = 0; + if (band === 'skilled') { + const rescue = finish(selected, 'rescue'); + if (rescue.decision?.kind !== 'sukRescue') throw Error('Missing original skilled Suk rescue'); + assert.equal(player(rescue, f.ixians).tanks, original.tanks, 'No physical losses occur before the skilled rescue choice'); + choice = rescue.decision.options.findIndex(o => o.normal === 0 && o.elite === 1 && o.kept?.kind === 'elite' && o.kept.key === f.location); + assert.ok(choice >= 0); event = rescue.decision.event; + selected = step(rescue, { actor: f.ixians, action: { type: 'decision', event, choice } }); + } else { + assert.equal(selected.lastBattleContext!.sukRescue!.completed, true, 'The sole normal elite rescue is applied automatically'); + } + const pending = finish(selected, 'substitution'); + assert.equal(pending.decision?.kind, 'ixSubstitution'); + assert.deepEqual(pending.techTokens, revealed.techTokens, 'Physical rescue/substitution must precede original Tech transfer'); + const p = player(pending, f.ixians); + assert.equal(p.tanks, original.tanks + 2); assert.equal(p.elites!.tanks, original.elites!.tanks + 2); + assert.equal(p.battleLosses, original.battleLosses + 2); + assert.equal(p.reserves, original.reserves + (band === 'normal' ? 1 : 0)); + assert.equal(p.elites!.reserves, original.elites!.reserves + (band === 'normal' ? 1 : 0)); + assert.equal(p.forces[f.location], band === 'normal' ? 3 : 4); + assert.equal(p.elites!.forces[f.location] ?? 0, band === 'normal' ? 0 : 1); + assert.equal(p.spice, original.spice - (advanced && !mode.strongholds ? 1 : 0)); + reject(pending, f.ixians, { type: 'decision', event, choice }); + reject(pending, f.ixians, { type: 'decision', sources: { [f.location]: 2 }, recover: { [f.location]: 3 } }); + const exchanged = step(pending, { actor: f.ixians, action: { type: 'decision', sources: { [f.location]: 2 }, recover: { [f.location]: 2 } } }); + const settled = finish(exchanged), after = player(settled, f.ixians); + assert.equal(after.forces[f.location], p.forces[f.location]); + assert.equal(after.elites!.forces[f.location], band === 'normal' ? 2 : 3); + assert.equal(after.tanks, p.tanks, 'Equal physical exchange does not count the original Cyborg loss twice'); + assert.equal(after.elites!.tanks, original.elites!.tanks); + assert.equal(after.battleLosses, p.battleLosses); + assert.equal(after.reserves, p.reserves); assert.equal(after.elites!.reserves, p.elites!.reserves); + assert.equal(settled.lastBattleContext!.sukRescue!.completed, true); + assert.equal(settled.lastBattleContext!.ixSubstitution!.completed, true); + money(revealed, settled); reward(f, settled); custody(settled, f.afterSetup); + }); +} + +for (const mode of MODES) void test(`paired E1 Skills/modules ${JSON.stringify(mode)} Face Dance: Suk/card cleanup/rewards finish first; partial Cunning draws before retirement`, () => { + const f = fixture({ ...mode, program: 'faceDance', skill: 'suk-graduate' }); + f.planActions[1].action.dial = 1; + f.planActions[1].action.support = mode.advanced ? 1 : 0; + const revealed = reveal(f, { cunning: true }), quote = quoteBattle(revealed); + const enemy = player(revealed, f.opponent).leaders.find(l => l.id === f.planActions[1].action.leader)!; + assert.deepEqual(quote.bounty, { player: f.ixians, amount: enemy.strength }); + const cards = finish(revealed, 'cards'); + assert.equal(cards.decision?.kind, 'battleCards'); + reject(cards, f.tleilaxu, { type: 'decision', reveal: true, sources: { reserves: 1 }, sector: territory('sietch_tabr').sectors[0] }); + assert.equal(cards.lastBattleContext!.sukRescue!.completed, true); + const defense = f.planActions[0].action.defense as string; + const cleaned = step(cards, { actor: f.ixians, action: { type: 'decision', discard: [defense] } }); + const rewarded = finish(cleaned, 'tech'); + reward(f, rewarded); + const pending = finish(rewarded, 'faceDance'); + assert.ok(pending.discard.some(c => c.id === defense)); + assert.equal(player(pending, f.opponent).leaders.find(l => l.id === enemy.id)!.dead, true); + money(revealed, pending); reward(f, pending); + assert.ok(!pending.pendingTech, 'Mandatory native Tech reward finishes before matching Face Dance'); + if (mode.strongholds) assert.deepEqual(quote.strongholdIncome, [{ player: f.ixians, amount: 1 }]); + const winner = structuredClone(player(pending, f.ixians)); + const oldStock = structuredClone(player(pending, f.tleilaxu).faceDancers!); + assert.ok(oldStock.some(c => c.leader === f.trainer && !c.revealed)); + reject(pending, f.tleilaxu, { type: 'decision', reveal: true, sources: { reserves: winner.forces[f.location] + 1 }, sector: territory('sietch_tabr').sectors[0] }); + const after = dance(f, pending), returned = player(after, f.ixians), replacement = player(after, f.tleilaxu); + assert.equal(returned.forces[f.location] ?? 0, 0); assert.equal(returned.elites!.forces[f.location] ?? 0, 0); + assert.equal(returned.reserves, winner.reserves + winner.forces[f.location]); + assert.equal(returned.elites!.reserves, winner.elites!.reserves + (winner.elites!.forces[f.location] ?? 0)); + assert.equal(returned.tanks, winner.tanks); assert.equal(returned.battleLosses, winner.battleLosses); + assert.equal(returned.elites!.tanks, winner.elites!.tanks); + assert.equal(returned.leaders.find(l => l.id === f.trainer)!.deaths, winner.leaders.find(l => l.id === f.trainer)!.deaths + 1); + assert.equal(returned.leaders.find(l => l.id === f.trainer)!.dead, true); + assert.equal(after.leaderSkills!.assignments.some(a => a.leader === f.trainer), false); + assert.equal(after.leaderSkills!.deck.filter(s => s === f.skill).length, 1); + assert.deepEqual(returned.hand, winner.hand); + for (const p of pending.players) assert.equal(player(after, p.id).spice, p.spice + city(after, p.id), 'Face Dance grants no extra bounty/support charge'); + assert.equal(replacement.forces[f.location], winner.forces[f.location]); + assert.equal(replacement.forces[f.replacementSource], 1); + assert.equal(replacement.reserves, player(pending, f.tleilaxu).reserves - winner.forces[f.location] + 2); + const revealedStock = replacement.faceDancers!.filter(c => c.revealed); assert.equal(revealedStock.length, 1); + const untouched = replacement.faceDancers!.filter(c => !c.revealed); + assert.deepEqual(untouched, oldStock.filter(c => c.leader !== f.trainer)); + assert.deepEqual(after.techTokens, pending.techTokens, 'Replacement cannot confiscate the earned token'); + if (mode.strongholds) { + assert.equal(strongholdControllers(after.players, true).sietch_tabr, f.tleilaxu); + assert.equal(strongholdBenefit(after.strongholdCards, f.ixians, 'sietch_tabr'), 'sietch_tabr'); + assert.equal(strongholdBenefit(after.strongholdCards, f.tleilaxu, 'sietch_tabr'), null); + } + const view = viewGame(after, f.tleilaxu), offer = view.nexusTleilaxu!.cunning!; + const action = nexusFaceDancersAction(view, offer.event); assert.ok(action); + const reserve = [...after.traitorReserve!], allowance = replacement.faceDancerReplacedTurn; + reject(after, f.tleilaxu, { ...action, leaders: [f.trainer] }); + const replaced = step(after, { actor: f.tleilaxu, action }); + assert.deepEqual(player(replaced, f.tleilaxu).faceDancers!.filter(c => untouched.some(old => old.leader === c.leader)), untouched); + assert.deepEqual(player(replaced, f.tleilaxu).faceDancers!.filter(c => !untouched.some(old => old.leader === c.leader)), [{ leader: reserve[0], revealed: false }]); + assert.notEqual(reserve[0], f.trainer); + assert.deepEqual([...replaced.traitorReserve!].sort(), [...reserve.slice(1), f.trainer].sort()); + assert.equal(player(replaced, f.tleilaxu).faceDancerReplacedTurn, allowance); + assert.equal(replaced.nexusCards!.cards!.hands[f.tleilaxu], null); + reject(replaced, f.tleilaxu, action); custody(replaced, f.afterSetup); + const mentat = advance(replaced, g => g.phase === 8 && !g.phaseOpening && !g.response && !g.decision); + const ordinary = step(mentat, { actor: f.tleilaxu, action: { type: 'replaceFaceDancer', leader: untouched[0].leader } }); + const done = advance(ordinary, g => !g.response && !g.decision); + assert.equal(player(done, f.tleilaxu).faceDancerReplacedTurn, done.turn); + reject(done, f.tleilaxu, { type: 'replaceFaceDancer', leader: player(done, f.tleilaxu).faceDancers![0].leader }); + const turn = finishTurn(done); + if (mode.strongholds) assert.equal(turn.strongholdCards!.owners.sietch_tabr, f.tleilaxu); + assert.equal(turn.leaderSkills!.deck.filter(s => s === f.skill).length, 1); custody(turn, f.afterSetup); +}); + +void test('held Arrakeen and original HMS copy subsidize the payer, not the supported Suk casualty commitment', () => { + const f = fixture({ tech: true, strongholds: true, mobileBattle: true }); + const copy = advance(f.game, g => g.decision?.kind === 'strongholdCopy'); + assert.equal(copy.decision?.kind, 'strongholdCopy'); + reject(copy, f.ixians, f.planActions[0].action); + const prepared = prepare(f, copy); + assert.equal(viewGame(prepared, f.opponent).battle!.strongholdEffects[f.ixians], 'arrakeen'); + const revealed = reveal(f, { state: prepared, cunning: true }), quote = quoteBattle(revealed); + assert.equal(quote.payments.find(p => p.player === f.ixians)!.ownPayment, 0); + assert.equal(quote.payments.find(p => p.player === f.ixians)!.bankSupport, 1); + const rescue = finish(chooseCyborgLosses(revealed, f), 'rescue'); + if (rescue.decision?.kind !== 'sukRescue') throw Error('Original copy-funded Suk rescue is required'); + assert.deepEqual(rescue.pendingSukRescue!.losses, { normal: 0, elite: 3, paidNormal: 0, paidElite: 1 }); + assert.equal(player(rescue, f.ixians).tanks, player(revealed, f.ixians).tanks); + const settled = finish(rescue); + money(revealed, settled); reward(f, settled); custody(settled, f.afterSetup); +}); + +void test('own sealed plan cannot consume Cunning and four legal policies preserve actual paid plans, typed rescue and Face Dance sources', () => { + const f = fixture({ tech: true, strongholds: true, program: 'faceDance' }); + const prepared = prepare(f), offer = viewGame(prepared, f.ixians).nexusSuboids!.offer!; + const sealed = step(prepared, f.planActions[0]); + reject(sealed, f.ixians, { type: 'nexusSuboids', event: offer.event }); + const revealed = reveal(f, { cunning: true }); + const rescue = finish(chooseCyborgLosses(revealed, f), 'rescue'); + if (rescue.decision?.kind !== 'sukRescue') throw Error('Original typed policy rescue is required'); + const face = finish(revealed, 'faceDance'); + for (const difficulty of DIFFICULTIES) { + const opening = viewGame(prepared, f.ixians); opening.players.find(p => p.id === f.ixians)!.bot = difficulty; + const cunningAction = botActions(opening).find(a => a.type === 'nexusSuboids'); assert.ok(cunningAction); + const active = advance(step(prepared, { actor: f.ixians, action: cunningAction }), g => !g.response && !g.decision && !g.phaseOpening); + const own = viewGame(active, f.ixians); own.players.find(p => p.id === f.ixians)!.bot = difficulty; + const plan = botActions(own).find(a => a.type === 'battlePlan'); assert.ok(plan); + const committed = step(active, { actor: f.ixians, action: plan }); + assert.ok(committed.battle!.plans[f.ixians]); + assert.equal(player(committed, f.ixians).spice, player(active, f.ixians).spice); + const rescueView = viewGame(rescue, f.ixians); rescueView.players.find(p => p.id === f.ixians)!.bot = difficulty; + const rescueAction = botActions(rescueView).find(a => a.type === 'decision'); assert.ok(rescueAction); + const saved = step(rescue, { actor: f.ixians, action: rescueAction }); + assert.equal(player(saved, f.ixians).tanks, player(revealed, f.ixians).tanks); + assert.equal(player(saved, f.ixians).forces[f.location], 4); + custody(finish(saved), f.afterSetup); + const view = viewGame(face, f.tleilaxu); view.players.find(p => p.id === f.tleilaxu)!.bot = difficulty; + const action = botActions(view).find(a => a.type === 'decision'); assert.ok(action && action.reveal === true); + const after = step(face, { actor: f.tleilaxu, action }); + assert.equal(player(after, f.ixians).forces[f.location] ?? 0, 0); + assert.ok(player(after, f.tleilaxu).forces[f.location] > 0); + assert.equal(after.leaderSkills!.assignments.some(a => a.leader === f.trainer), false); + assert.deepEqual(after.techTokens, face.techTokens); custody(after, f.afterSetup); + } +}); + +void test('original authenticated saved hands/offers survive module continuation; absent skill and foreign disc are rejected', () => { + const lobby = createGame('PAIREDIXSKILLSMODULESAUTH', newPlayer('http-ix', 'Ix', 'ixians'), true, ['ix']); + for (const [id, faction] of [['http-tl', 'tleilaxu'], ['http-guild', 'guild'], ['http-emperor', 'emperor']] as const) + joinGame(lobby, newPlayer(id, faction, faction)); + const fresh = fixture({ initial: lobby, tech: true, strongholds: true, skill: 'suk-graduate' }); + const saved = structuredClone(fresh.offered), offer = saved.leaderSkills!.offers[fresh.ixians]; + reject(saved, fresh.ixians, { type: 'leaderSkill', event: offer.event, skill: 'suk-graduate', leader: player(saved, fresh.opponent).leaders[0].id }); + const unavailable = LEADER_SKILL_CARDS.find(c => !offer.cards.includes(c.id))!.id; + assert.throws(() => fixture({ initial: saved, skill: unavailable })); + const continued = fixture({ initial: saved, skill: 'suk-graduate' }); + assert.deepEqual(continued.initial, saved); + assert.deepEqual(continued.afterSetup.players.map(p => [p.id, p.hand]), saved.players.map(p => [p.id, p.hand])); + assert.deepEqual(continued.offered.leaderSkills!.offers, saved.leaderSkills!.offers); + const ready = prepare(continued), offer2 = viewGame(ready, 'http-ix').nexusSuboids!.offer!; + reject(ready, 'http-tl', { type: 'nexusSuboids', event: offer2.event }); + const active = cunning(continued, ready); + assert.equal(active.nexusSuboidLast!.owner, 'http-ix'); + custody(active, continued.afterSetup); +}); + +void test('two native seats may initialize Strongholds/Skills but an unallied closing draw cannot be fabricated', () => { + const lobby = createGame('PAIREDIXTWO', newPlayer('ix', 'Ix', 'ixians'), true, ['ix']); + joinGame(lobby, newPlayer('tl', 'Tleilaxu', 'tleilaxu')); + const original = setup({ initial: lobby, tech: false, strongholds: true, skill: 'suk-graduate' }); + const storm = advance(original.game, g => g.phase === 1 && !g.phaseOpening && !g.response && !g.decision); + assert.equal(storm.turn, 1); + assert.ok(Object.values(storm.nexusCards!.cards!.hands).every(c => c === null)); + reject(storm, 'ix', { type: 'nexusCardChoice', turn: 1, card: null, choice: 'draw', ownRedraws: 0 }); + assert.throws(() => fixture({ initial: original.offered }), /Two classics/); +}); diff --git a/tools/faction-games.ts b/tools/faction-games.ts index 97747bfa..b81e27b3 100644 --- a/tools/faction-games.ts +++ b/tools/faction-games.ts @@ -43,7 +43,8 @@ const DIFFICULTIES = ['Easy', 'Medium', 'Hard', 'Brutal'] as const; type Profile = 'base' | 'choam' | 'ecaz' | 'ecaz-treachery' | 'ecaz-occupy' | 'moritani-assassinate' | 'stronghold-factions' | 'combined' | 'combined-nexus' | 'combined-homeworld-nexus' | 'homeworld' | 'nexus' | 'homeworld-nexus' | 'choam-roster' | 'ecaz-roster' | 'ix-roster' | 'choam-nexus' | 'ecaz-nexus' | 'ix-nexus' | 'moritani-skills' | 'tleilaxu-skills' | 'ix-skills' | 'choam-skills' | 'richese-skills' | 'ecaz-skills' | 'skills-tech' | 'skills-stronghold' | 'skills-stronghold-tech' | 'factions-tech' | 'stronghold-factions-tech' | 'nexus-tech' | 'nexus-stronghold' | 'nexus-stronghold-tech' | 'paired-nexus-tech' | 'paired-nexus-stronghold' | 'paired-nexus-stronghold-tech' | 'nexus-skills' | 'paired-nexus-skills' - | 'nexus-skills-tech' | 'nexus-skills-stronghold' | 'nexus-skills-stronghold-tech'; + | 'nexus-skills-tech' | 'nexus-skills-stronghold' | 'nexus-skills-stronghold-tech' + | 'paired-nexus-skills-tech' | 'paired-nexus-skills-stronghold' | 'paired-nexus-skills-stronghold-tech'; type Rules = 'basic' | 'advanced'; type Scenario = { @@ -129,6 +130,8 @@ const NEXUS_SKILLS_MODULE_SCENARIOS: readonly Scenario[] = .map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 1510 + band * 50 + index }))); const nexusSkillsModuleProfile = (profile: string) => profile === 'nexus-skills-tech' || 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 MODULE_SCENARIOS: readonly Scenario[] = [ ...[2, 3, 4, 5, 6].flatMap((players) => (['basic', 'advanced'] as const).map((rules, index) => ({ @@ -167,13 +170,15 @@ 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); + pairedNexusModuleProfile(profile) || nexusSkillsModuleProfile(profile) || pairedNexusSkillsModuleProfile(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 === 'nexus-skills-tech' || profile === 'nexus-skills-stronghold-tech' || + profile === 'paired-nexus-skills-tech' || profile === 'paired-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 === 'nexus-skills-stronghold' || profile === 'nexus-skills-stronghold-tech' || + profile === 'paired-nexus-skills-stronghold' || profile === 'paired-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[] = [ @@ -238,6 +243,12 @@ const PAIRED_NEXUS_SCENARIOS: readonly Scenario[] = EXPANSION_ROSTER_SCENARIOS const PAIRED_NEXUS_SKILLS_SCENARIOS: readonly Scenario[] = PAIRED_NEXUS_SCENARIOS .filter(scenario => scenario.profile !== 'ecaz-nexus') .map((scenario, index): Scenario => ({ ...scenario, profile: 'paired-nexus-skills', ordinal: 1450 + index })); +const PAIRED_NEXUS_SKILLS_MODULE_SCENARIOS: readonly Scenario[] = + (['paired-nexus-skills-tech', 'paired-nexus-skills-stronghold', 'paired-nexus-skills-stronghold-tech'] as const) + .flatMap((profile, band) => PAIRED_NEXUS_SKILLS_SCENARIOS + .filter(scenario => (profile === 'paired-nexus-skills-tech' || scenario.rules === 'advanced') && + (profile === 'paired-nexus-skills-stronghold' || scenario.roster.length >= 3)) + .map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 1700 + band * 100 + index }))); const PAIRED_NEXUS_MODULE_SCENARIOS: readonly Scenario[] = (['paired-nexus-tech', 'paired-nexus-stronghold', 'paired-nexus-stronghold-tech'] as const) .flatMap((profile, band) => PAIRED_NEXUS_SCENARIOS @@ -430,7 +441,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) => 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) => 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)); @@ -471,7 +482,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|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|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.' + @@ -488,6 +499,7 @@ function usage() { + '\nNexus-skills uses classic/base33/all14 Skills/all12 Nexus, Basic/Advanced two through six seats, with original setup/posture, actual closing draws and physical effects. Native factions, Tech/Strongholds/HW, Banker/Mentat previews, borrowed Smuggler shipment arithmetic and unresolved effects remain guarded; no public start or played-game conversion.' + '\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.' ); } @@ -542,7 +554,7 @@ 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 (pairedNexusModuleProfile(scenario.profile) || scenario.profile === 'paired-nexus-skills') { + 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}`; } @@ -671,7 +683,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].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].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 && @@ -903,6 +915,7 @@ async function main() { : pairedNexusModuleProfile(profile) ? PAIRED_NEXUS_MODULE_SCENARIOS : profile === 'nexus-skills' ? NEXUS_SKILLS_SCENARIOS : profile === 'paired-nexus-skills' ? PAIRED_NEXUS_SKILLS_SCENARIOS + : pairedNexusSkillsModuleProfile(profile) ? PAIRED_NEXUS_SKILLS_MODULE_SCENARIOS : nexusSkillsModuleProfile(profile) ? NEXUS_SKILLS_MODULE_SCENARIOS : nexusModuleProfile(profile) ? CLASSIC_NEXUS_MODULE_SCENARIOS : profile === 'choam-roster' || profile === 'ecaz-roster' || profile === 'ix-roster' ? EXPANSION_ROSTER_SCENARIOS diff --git a/tools/start-prototype.ts b/tools/start-prototype.ts index 5a1d1063..254bad66 100644 --- a/tools/start-prototype.ts +++ b/tools/start-prototype.ts @@ -33,8 +33,8 @@ 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, Tech/Strongholds/HW, Banker/Mentat previews, borrowed Smuggler/pair-companion arithmetic, public starts and unresolved interactions stay separate.', - '\nIn the classic/base33/all14/all12 skill-Nexus entry, selected unused original Tech Tokens additionally require3–6 Basic/Advanced; selected Stronghold Cards require2–6 Advanced; both require3–6 Advanced. Real first-Storm/end-Mentat custody, phase-end industry, held subsidy, skill rescue and mandatory original-winner Tech use original handlers. Paired/native skill-Nexus remains without Tech/Strongholds in this bound.', + '\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.', + '\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.', ); return; }