diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index c2fd090e..c48e1cb7 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,27 @@ open mode gates. ## Current checkpoint and work +**Paired E1/E2 Nexus / Tech / Strongholds — bounded Development Verified:** +fresh both Ixians+Tleilaxu OR CHOAM+Richese plus classics/one family deck +preserves Tech3..6 Basic/Advanced and Strongholds2..6 Advanced without Skills. +New18/affected139, types/lint/build, eight actual native programs and all four +legal paired-shipment/Face-Dance policies pass. Eight genuine five-seat games +complete3,926 accepted actions/no rejection/102 JSON continuations and six +real closing Nexus choices; natural Cunning use is not claimed. +Original backed-up CLI **7N2FKGKW / EFQGWA8F v10/setup** preserves actors +and first deals. Human390px **7N2FKGKW v30/Collection** activates full-turn +Suboids; actual Arrakeen and HMS-copy support each pays bank1/own0. +Three lost Cyborgs return by equal three-Suboid sacrifice; a subsequent original +Dominic Face Dancer returns two remaining winners and places two Tleilaxu +reserves inside HMS. Original Heighliners/Production remain at Ix; Tleilaxu +Cunning replaces only the revealed physical card and keeps Burseg/Soo-Soo. +**EFQGWA8F v15/Battle** sends immediate5/concealed3 to Habbanya17 for +Guild1 (Richese4→3/Guild6→7); real CHOAM Heighliners1 pays only at movement +end, wallet19→20. Original held cards, private controls/inspector/refresh and +no root390px overflow remain. Controlled positions are labeled and only QA +rooms change. [Scope, commands and bounded evidence](NEXUS_CARD_RULES.md#paired-e1e2-tech-and-stronghold-composition--4-october-2026) +preserve mixed-family/E3/Skills/HW/public/deployment and pending-ruling gates. + **Classic Nexus / Tech / Strongholds — bounded Development Verified:** fresh classic/base33/all12 Nexus composes Tech3..6 Basic/Advanced and/or Advanced Strongholds2..6, without Skills, public starts or new rulings. diff --git a/docs/NEXUS_CARD_RULES.md b/docs/NEXUS_CARD_RULES.md index d5a0a120..24e1b49a 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 E1/E2 Tech and Stronghold composition — 4 October 2026 + +The existing fresh local `nexus` entry additionally preserves unused original +Tech and/or Stronghold components with **both Ixians + Tleilaxu OR both +CHOAM + Richese**, classic opponents and exactly one corresponding family +deck. Tech requires3..6 Basic/Advanced; Strongholds requires2..6 Advanced; +both requires3..6 Advanced. Original47/35 Treachery, separate Richese cache, +all12 Nexus, native setup, first-Storm Tech and actual end-Mentat Stronghold +claims remain. No Skills, mixed families, E3, Homeworlds, independent cards, +unrelated previews, public starts or played-game conversion are admitted. + +This composes the same independent printed variants cited below, not a new +Nexus ruling. Original Ixian Cunning grants full unsupported Suboid strength +through the actual turn, while Cyborg support/casualties remain typed. +Arrakeen funds actual support only, including a legal HMS copy; it grants +no wallet income. Original equal Suboid sacrifice recovers lost Cyborgs. +Actual original winner Tech/cards/spice precede native Face Dance. Replacement +changes physical occupation, not prior rewards or held Stronghold ownership; +the next real end-Mentat claim follows the new controller. Tleilaxu Cunning +draws replacements before retiring revealed originals and preserves unrevealed +stock and the separate ordinary Mentat replacement allowance. + +Richese Cunning retains its existing two-physical-token/nonrepeat convention: +one reserve-capped immediate group, one concealed marker, one original tariff. +An allowed off-planet pair activates Heighliners once; stopped declarations +create no invoice, placement or activity. Industry is unavailable until the +original phase end. Voluntary revelation does not manufacture counters or +reset previous-marker history. Advanced held Habbanya can decide an ordinary +tied battle after revelation; this does not open mixed No-Field dialing. +CHOAM Cunning consumes one original Treachery fuel card through its existing +Worthless effect/response, without creating another physical card. + +```sh +node --import tsx tools/faction-games.ts --profile paired-nexus-tech \ + --players 5 --rules both --out /private/new-paired-nexus-tech +node --import tsx tools/faction-games.ts --profile paired-nexus-stronghold \ + --players 5 --rules advanced --out /private/new-paired-nexus-stronghold +node --import tsx tools/faction-games.ts --profile paired-nexus-stronghold-tech \ + --players 5 --rules advanced --out /private/new-paired-nexus-three-module +``` + +### Bounded paired development evidence + +New18/affected139 cases pass, plus final types/lint/build, eight actual native +rule programs and all four legal paired-shipment/Face-Dance policies. +Eight genuine five-seat games complete3,926 accepted actions, no rejection, +102 JSON continuations and six actual closing Nexus choices. Some finish +before a qualifying Nexus; none claims natural Cunning use. Report roots: +`/tmp/dune-paired-nexus-tech-games-20261004`, +`/tmp/dune-paired-nexus-stronghold-games-20261004` and +`/tmp/dune-paired-nexus-stronghold-tech-games-20261004`. +All capture unchanged tree +`3baa637122c51fbc8928c9931700914e9dc2ed8d2e8d1ec4a81d7bab49829e5c`; +later fixture lint and reference changes do not modify the engine. + +Original backed-up CLI **7N2FKGKW / EFQGWA8F v10/setup** preserves exact +authenticated actors and the first deal. Controlled390px **7N2FKGKW v30/ +Collection** activates native Cunning, seals C’tair5/dial4/support1 and +Dominic4/dial3/support1 in successive Arrakeen/HMS battles. Both actual +support units come from held/copied Arrakeen, with zero personal debit. +The first loses three Cyborgs then sacrifices three Suboids to recover them. +The second reveals the original Dominic Face Dancer, returns two remaining +winner counters, places two Tleilaxu reserves inside HMS, kills Dominic and +leaves original winner Heighliners/Production at Ix. Tleilaxu Cunning then +replaces only that revealed physical card with the actual next Traitor; +Burseg/Soo-Soo stay unchanged. The prior HMS card stays at Ix until end Mentat. + +**EFQGWA8F v15/Battle** sends immediate5 plus concealed3 to Habbanya17 +for original Guild1, Richese4→3 and Guild6→7. Real CHOAM Heighliners1 +stays pending until movement ends, then wallet19→20 with its token cleared. +Original Habbanya/Arrakeen cards remain retained. Human Cunning, token, +typed-casualty, equal-substitution and Face-Dance/replacement controls, +refresh and public ownership are observed without root390px overflow. +All controlled relocation is labeled; no wallet, phase, module-owner, +revealed-stock or hidden-hand grants are used. Only dedicated QA rooms change. + +Borrowed Voice, worm-protection reaction policy, Tleilaxu revival accounting, +Richese cache/mixed-plan rulings and general reactive Betrayal policy remain +guarded. Full rules, assurance, calibration, public and deployed gates stay open. + ## Classic Tech and Stronghold composition — 4 October 2026 The existing fresh local `nexus` entry preserves unused original Tech Tokens diff --git a/docs/NEXUS_RICHESE_RULES.md b/docs/NEXUS_RICHESE_RULES.md index 873fd8e1..1fd75a7e 100644 --- a/docs/NEXUS_RICHESE_RULES.md +++ b/docs/NEXUS_RICHESE_RULES.md @@ -70,6 +70,22 @@ The card is discarded at declaration. The native No-Field Karama cancellation pr An owner-only control distinguishes ordinary No-Field, Smuggler and Nexus pair choices. All four bot profiles have a minimal legal pair candidate. The pending frame binds both physical identities, owner, token event, destination and one-marker price through JSON and in-memory SQLite CAS recovery. Public logs disclose the immediate faceup value/actual force count after settlement; the rival projection contains no concealed token identity or owner-only offer. Signatures detect partial saved-frame corruption, not arbitrary save forgery. Combined modules, allied No-Fields, high-Richese additional-force effects, later faceup display mechanics and full Nexus acceptance remain open. Betrayal now has its separate bounded auction contract below; the Cunning evidence does not verify that path. +### Paired Tech/Stronghold composition — 4 October 2026 + +The fresh local paired CHOAM/Richese Nexus entry additionally preserves original +Tech Tokens at3..6 Basic/Advanced and/or Stronghold Cards at2..6 Advanced, +without Skills or other overlays. It retains the exact native pair, classic +opponents, CHOAM deck/cache and genuine closing deals. The existing pair +convention above is unchanged. Accepted off-planet placement activates actual +Heighliners once, collected at native phase end; a stopped pair activates none. +Immediate/later revelation, reserve cap, previous-token restriction and original +Guild invoice remain. After voluntary revelation, ordinary Advanced battle +plans compose retained Habbanya and original CHOAM income/Tech reward without +entering mixed No-Field dialing. [Scope and bounded evidence](NEXUS_CARD_RULES.md#paired-e1e2-tech-and-stronghold-composition--4-october-2026) +does not settle the separate cache, high-Richese, allied-marker, faceup-display +or public/full-module boundaries. + + ## Existing integration points and verification `game/shipment-price.ts` already separates ordinary reserve prices, Guild cross-shipment prices and contributor-filtered Guild income. The ordinary engine chain is `validatePhysicalShipment` → `offerShipment` → `commitShipment`; its checks use native reserve withdrawal, typed counters, arrival stance, actual geography and shipment promises. Introduce a validated charged-count basis without replacing the physical amount. Keep current low-Junction income and occupied-payment routing after the reduced gross is computed. diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 67ab92a1..7d2f0972 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -206,6 +206,7 @@ See [the cutover and Advanced sequence](MORITANI_ASSASSINATE_LEADERS.md#authorit | Advanced Stronghold faction integration | Fresh `stronghold-factions` admits selected E1/E2 native families and standalone Ecaz OR Moritani with classic2..6-seat Advanced, canonical selected decks and six Stronghold Cards; optional unused Tech Tokens requires3..6, without Leader Skills. Standalone E3 uses ecaz33; Moritani excludes Harkonnen. Ecaz co-control holds the card at Ecaz, but only the selected actual holder-plan receives its effect; fixed Ecaz strength is free, and Arrakeen funds only actual variable support. Losing Moritani retains printed Tuek income before original assassination, printed bounty and one actual Mentat replacement; winner cleanup finishes once and normal Traitor reveal forfeits assassination for the game. Existing E1/E2 No-Field/Stone/FaceDance and retained winner rewards remain. E3 pairs/mixed families, mixed No-Field/full-plan, Stone/Poison Tooth and other overlays stay guarded. This composes existing contracts, not a new ruling. Save/privacy/custody assurance follows all rules. | [E1/E2 scope](STRONGHOLD_CARDS.md#expanded-e1e2-faction-rules), [standalone E3 scope and evidence](STRONGHOLD_CARDS.md#standalone-e3-stronghold-composition--4-october-2026), [native Tech composition](EXPANSION_FACTIONS_PROTOTYPE.md#native-tech-composition--4-october-2026) | | Native Tech without Leader Skills | Fresh3..6 Basic/Advanced selected E1/E2 native families or standalone Ecaz OR Moritani plus classics preserve original unused three Tech Tokens through `factions`; Advanced Strongholds use the existing native entry. Physical setup, printed/first-Storm owners, phase-end income and single-player complete-set victory are unchanged. Actual Ecaz selected lead receives the original opposing token, never a joint allied holding; Basic odd Occupy remains guarded. Moritani original forfeiture/assassination and winner Tech before Face Dance reuse existing handlers. No new ruling, wallet grant, public start or save conversion. | [Original composition/source and evidence](EXPANSION_FACTIONS_PROTOTYPE.md#native-tech-composition--4-october-2026), [Stronghold contract](STRONGHOLD_CARDS.md#native-tech-composition-without-skills--4-october-2026) | | 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) | | 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) | | Guild Secret Ally | One explicit Guild-priced ordinary shipment; borrowed prices/routes preserve native identity. Free Fremen reinforcement remains a separate choice. | [Route contract](NEXUS_GUILD_SECRET_ALLY_RULES.md), [verified runtime](NEXUS_GUILD_SECRET_ALLY_RUNTIME.md) | | Homeworld custody and mobility | Physical home/visitor groups, population thresholds, tariffs and transport permissions retain distinct sources; Homeworld immunity is not universal immunity. | [Homeworld rules](HOMEWORLD_RULES.md), [benefits](HOMEWORLD_BENEFITS_RULES.md), [mobility](HOMEWORLD_MOBILITY_RULES.md), [invasion](HOMEWORLD_INVASION_RULES.md), [Guild transport](GUILD_HOMEWORLD_TRANSPORT_RULES.md) | diff --git a/game/engine.ts b/game/engine.ts index ca672636..88a733b6 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1,4 +1,4 @@ -import { classicNexusModulesProfile } from './nexus-module-profile'; +import { classicNexusModulesProfile, pairedNexusModulesProfile } from './nexus-module-profile'; import { nativeFactionTechProfile } from './faction-module-profile'; import { advancedMoritaniLeaderSkillsProfile, advancedNativeLeaderSkillsProfile, classicTechLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, strongholdLeaderSkillsProfile } from './leader-skill-profile'; import { bribeTimingBlock, maximumBribe, type BribeOptions } from './bribe-options'; @@ -9198,8 +9198,9 @@ export function initializePairedNexusGameForAudit(state: Game): Game { state.players.some(player => player.faction === 'tleilaxu'))), 'The paired Nexus sample requires both selected expansion factions and their deck.'); requireRule(!!state.nexusCards && state.nexusCards.cards === null && state.nexusCards.phase === null && - !state.homeworlds && !state.leaderSkills && !state.discoveryEnabled && !state.ecazTreachery, - 'Enable only Nexus Cards in a fresh paired expansion lobby.'); + !state.homeworlds && !state.leaderSkills && !state.discoveryEnabled && !state.ecazTreachery && + ((!state.techTokens && !state.strongholdCards) || pairedNexusModulesProfile(state)), + 'Enable Nexus Cards with only supported unused paired E1/E2 Tech or Advanced Stronghold components.'); return initializeSetupGameForAudit(state, false, true, false, false, false, false, true); } @@ -9432,7 +9433,7 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f const skillStronghold = leaderSkills && strongholdLeaderSkillsProfile(g); const skillTech = leaderSkills && (classicTechLeaderSkillsProfile(g) || nativeTechLeaderSkillsProfile(g) || skillStronghold); const nativeTech = factions && !leaderSkills && nativeFactionTechProfile(g); - const nexusModules = nexus && classicNexusModulesProfile(g); + const nexusModules = nexus && (classicNexusModulesProfile(g) || pairedNexusModulesProfile(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/nexus-module-profile.ts b/game/nexus-module-profile.ts index 575f0f35..97ce6644 100644 --- a/game/nexus-module-profile.ts +++ b/game/nexus-module-profile.ts @@ -23,3 +23,33 @@ export function classicNexusModulesProfile(game: FactionModuleProfile): boolean } return true; } + +/** Both original natives in one E1 or E2 family; mixed decks/families stay separate. */ +export function pairedNexusModulesProfile(game: FactionModuleProfile): boolean { + if (typeof game.advanced !== 'boolean' || !game.nexusCards || + (!game.techTokens && !game.strongholdCards) || game.expansions.length !== 1 || + game.players.length < 2 || game.players.length > 6 || + (game.techTokens && game.players.length < 3) || + (game.strongholdCards && !game.advanced) || game.leaderSkills || game.homeworlds || + game.discoveryEnabled || game.discoveries || game.discoveryStash || game.greatMaker || + game.ecazTreachery || game.semutaPreview || game.advancedPreview || + game.kullPreview || game.nexusKullPreview || game.guildBetrayalPreview || + game.richeseBetrayalPreview || game.nexusIxianReplacementPreview || + game.nexusIxianBetrayalPreview || game.nexusHarkonnenBetrayalPreview) return false; + const expansion = game.expansions[0]; + if (expansion !== 'ix' && expansion !== 'choam') return false; + const primary = expansion === 'ix' ? 'ixians' : 'choam'; + const secondary = expansion === 'ix' ? 'tleilaxu' : 'richese'; + let hasPrimary = false, hasSecondary = false; + for (let i = 0; i < game.players.length; i++) { + const player = game.players[i]; + for (let j = 0; j < i; j++) if (player.faction === game.players[j].faction) return false; + let family: string | undefined; + for (const faction of FACTIONS) + if (faction.id === player.faction) { family = faction.expansion; break; } + if (family !== 'base' && family !== expansion) return false; + if (player.faction === primary) hasPrimary = true; + if (player.faction === secondary) hasSecondary = true; + } + return hasPrimary && hasSecondary; +} diff --git a/game/reference.ts b/game/reference.ts index ce240870..3bb529fe 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -4366,6 +4366,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Entering any alliance discards your held Nexus Card, including alliances made through an Ambassador or Terror token. An offer that has not been accepted does not discard it.', 'Use Cunning for your own faction, Betrayal when another player controls the printed faction, and Secret Ally when that faction is absent. Used cards go to the separate Nexus discard pile. Each printed effect has its own timing.', 'Fresh local classic Nexus tables 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.', '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.', @@ -4401,7 +4402,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Fremen Betrayal has two bounded advance-declaration alternatives for an unallied holder in a classic Nexus table. Before the first Spice Blow or worm appearance, spend the physical card to prevent Fremen ordinary and Cunning worm rides for the turn; worm survival, destruction and Nexus still resolve. This clean timing is a product boundary, not an official post-appearance priority ruling. Instead, before Fremen move during Shipment and Movement, spend it to suppress their native two-territory advantage for that turn while ordinary movement and independent ornithopters remain. Only one alternative can be used even if the card is recycled later that turn. Owner controls show the currently available choice; combined modules and reactive post-worm timing remain gated.', 'When Ecaz is absent, an unallied holder may spend its Nexus card at a quiet play boundary to ask whether any one player, including themself, holds one of the holder’s native faction leaders as an ordinary Traitor Card. The owner receives a private yes/no answer saved at the moment of use; no name or count is disclosed. Dead or captured native leaders count, but foreign Gholas, Duke Vidal, Cheap Hero and Face Dancers do not. Native-leader scope and private-only audience are explicit product interpretations, not publisher rulings; exceptional controlled-leader and public-disclosure combinations remain open.', 'When Richese is absent, its Secret Ally replaces your ordinary reserve shipment with up to five physical forces charged as one. Select the explicit shipment checkbox; forces, elite identity, destination restrictions, Homeworld reserve sources and normal movement remain unchanged. Fremen remain free within their normal reinforcement range; Guild and an active Karama rate retain their existing tariff and payment routing. This is not a concealed No-Field or an extra shipment.', - 'Native Richese Cunning has a bounded two-No-Field shipment in genuine Richese/CHOAM Nexus games without other optional modules. Choose distinct unused tokens: one reveals immediately into reserve-capped public forces, the other remains concealed; pay for one marker and spend the Nexus. The previous shipment token cannot be either selection; the concealed token becomes next last-used. Karama or Guild special prevention preserves physical tokens and unpaid spice, with the ordinary stopped-shipment allowance policy. Other module combinations and the full faceup-component display remain gated.', + 'Native Richese Cunning has a bounded two-No-Field shipment in genuine Richese/CHOAM Nexus games, including the supported fresh Tech/Advanced Stronghold composition without Skills. Choose distinct unused tokens: one reveals immediately into reserve-capped public forces, the other remains concealed; pay for one marker and spend the Nexus. The previous shipment token cannot be either selection; the concealed token becomes next last-used. Karama or Guild special prevention preserves physical tokens and unpaid spice, with the ordinary stopped-shipment allowance policy. An accepted off-planet pair accrues Heighliners once, collected only at phase end. Other module combinations and the full faceup-component display remain gated.', 'Richese Betrayal has a separate opt-in auction preview. Before payment, an unallied rival may spend its physical Richese Nexus card to discard a public self-cache win without any purchase debit, or redirect another buyer’s cache/Black Market payment to the bank while preserving delivery and earned bonus. Every publicly possible holder acknowledges the same opportunity; private Use eligibility cannot determine public timing. Normal hidden self-purchases and private special-Karama acquisitions are not connected. See the dedicated guide for its existing local runtime/browser evidence and remaining release gates; that evidence does not verify Nexus Kull.', 'Guild Cunning is declared when finishing your ordinary Shipment and Movement turn. It spends the Nexus and opens one Karama response. Allowance grants a second native shipment at normal Guild prices, including supported cross-shipment, return and Homeworld routes. Cancellation leaves the completed first turn intact. Only an unused Hajr move can follow the second shipment; no ordinary movement or third move is granted. The physical Ornithopter card combination remains unavailable. The separate guild-betrayal profile admits only original native shipment producers and explicitly blocks this second-shipment offer before Nexus spending; Cunning outside that profile is unchanged.', 'With Guild absent, its Secret Ally is an explicit optional source for one ordinary shipment at Guild prices. It supports physical reserve shipment, cross-shipment and return to abstract reserves, plus half-price independently legal world-to-world shipment. Fremen may retain free reinforcement or choose the paid card route beyond its normal range, outside the storm. Native faction, typed sources and normal movement stay unchanged. Homeworld reserve return awaits a ruling; arbitrary foreign-world transport and concealed No-Fields are not granted.', @@ -4412,11 +4413,11 @@ export const RULE_TOPICS: RuleTopic[] = [ ], related: ['ecaz-modules', 'spice-blow', 'alliance-funding', 'choam-worthless', 'choam-kull', 'nexus-choam-kull', 'nexus-richese-betrayal', 'nexus-guild-betrayal', 'nexus-ixian-replacement', 'nexus-ixian-betrayal'], checklist: [ - { area: 'Implementation', status: 'Partial', detail: 'Physical lifecycle, Atreides inspections, Harkonnen exchanges/declarations, Face Dancers, Suboids, advisors, native Voice, Emperor Sardaukar and Betrayal, bounded Fremen revival/Cunning, CHOAM effects, Moritani Cunning/Betrayal, Ecaz Betrayal, native Duke Cunning and private Secret Ally inquiry, Richese shipment and bounded auction Betrayal, and bounded Emperor bank-auction purchase are integrated. Ecaz Cunning transfers the existing Duke at a quiet Battle boundary with a real card and distinct turn-end expiry. Ecaz inquiry scope/audience and Emperor purchase timing are provisional. Borrowed Voice, exceptional Duke custody, other reactions, Tleilaxu revival, Fremen worm protection and combined Cunning interactions remain unfinished.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/nexus-ecaz-inquiry.ts', 'game/nexus-ecaz-duke.ts', 'game/duke-vidal.ts', 'game/nexus-emperor-secret-ally.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/engine.ts'] }, + { area: 'Implementation', status: 'Partial', detail: 'Physical lifecycle, Atreides inspections, Harkonnen exchanges/declarations, Face Dancers, Suboids, advisors, native Voice, Emperor Sardaukar and Betrayal, bounded Fremen revival/Cunning, CHOAM effects, Moritani Cunning/Betrayal, Ecaz Betrayal, native Duke Cunning and private Secret Ally inquiry, Richese shipment and bounded auction Betrayal, and bounded Emperor bank-auction purchase are integrated. Ecaz Cunning transfers the existing Duke at a quiet Battle boundary with a real card and distinct turn-end expiry. Ecaz inquiry scope/audience and Emperor purchase timing are provisional. Borrowed Voice, exceptional Duke custody, other reactions, Tleilaxu revival, Fremen worm protection and combined Cunning interactions remain unfinished.', evidence: ['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/nexus-ecaz-inquiry.ts', 'game/nexus-ecaz-duke.ts', 'game/duke-vidal.ts', 'game/nexus-emperor-secret-ally.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/nexus-module-profile.ts', 'game/engine.ts'] }, { 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. 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'] }, + { 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. 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'] }, ], }, { diff --git a/tests/fixture-paired-choam-nexus-modules.ts b/tests/fixture-paired-choam-nexus-modules.ts new file mode 100644 index 00000000..e8c87916 --- /dev/null +++ b/tests/fixture-paired-choam-nexus-modules.ts @@ -0,0 +1,220 @@ +import assert from 'node:assert/strict'; +import { + applyAction, createGame, initializePairedNexusGameForAudit, joinGame, newPlayer, + viewGame, type Action, type Game, +} from '../game/engine'; +import { territory } from '../game/board'; +import type { Card } from '../game/cards'; +import { pairedNexusModulesProfile } from '../game/nexus-module-profile'; +import { validateNexusCards } from '../game/nexus-cards'; +import { nextNativeTechFactionsStep } from './fixture-native-tech-factions'; +import { orderNexusSpice } from './fixture-nexus-cards'; +import type { StrongholdFactionsNativeStep as Step } from './fixture-stronghold-factions'; + +export type PairedChoamNexusModulesOptions = { + initial?: Game; + advanced?: boolean; + tech?: boolean; + strongholds?: boolean; + reserveCap?: number; +}; +export type PairedChoamNexusModulesFixture = { + initial: Game; + afterSetup: Game; + afterFirstStorm: Game; + firstShipment: { before: Game; step: Step; after: Game }; + beforeFirstMentat: Game; + firstMentatStep: Step; + afterFirstMentat: Game; + movementStart: Game; + game: Game; + richese: string; + choam: string; + guild: string; + observer: string; + location: string; + actions: Step[]; + staging: string[]; +}; +export const pairedChoamPlayer = (game: Game, actor: string) => { + const player = game.players.find(p => p.id === actor); + assert.ok(player, `Missing original paired E2 actor ${actor}`); + return player; +}; +export const pairedChoamClean = (game: Game) => !game.phaseOpening && !game.response && !game.decision; +export function nextPairedChoamStep(game: Game): Step | null { + if (!game.phaseOpening && !game.response) { + const d = game.decision; + if (d?.kind === 'wormRide') return { actor: d.player, action: { type: 'decision', accept: false } }; + if (d?.kind === 'nexusFremenCunningOffer' || d?.kind === 'nexusFremenCunningRide') + return { actor: d.player, action: { type: 'decision', event: d.event, accept: false } }; + if (d?.kind === 'nexusChoamInspection') + return { actor: d.player, action: { type: 'decision', event: d.event, inspect: false } }; + } + return nextNativeTechFactionsStep(game); +} +function run(game: Game, step: Step, actions: Step[]): Game { + actions.push(structuredClone(step)); + return applyAction(game, step.actor, step.action); +} +export function advancePairedChoam(game: Game, until: (game: Game) => boolean, actions: Step[] = []): Game { + for (let n = 0; n < 1800; n++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished'); + const step = nextPairedChoamStep(game); + assert.ok(step, `Original paired E2 needs a human battle plan at ${game.turn}/${game.phase}`); + if (step.action.type === 'stormDial') step.action.amount = game.turn === 1 ? 0 : 1; + game = run(game, step, actions); + } + throw Error('Original paired E2 continuation did not reach its requested boundary'); +} +function setupEntropy(fn: () => T): T { + const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); + crypto.getRandomValues = (array: V): V => { + assert.ok(array); + new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255); + return array; + }; + try { return fn(); } + finally { + if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); + else Reflect.deleteProperty(crypto, 'getRandomValues'); + } +} +/** Continue the supplied authenticated, undealt original setup without changing + * actors, first deal or deck. Entropy is scoped only to a newly created lobby. */ +export function initializePairedChoamModules(options: PairedChoamNexusModulesOptions = {}): Game { + const tech = options.tech ?? true, strongholds = options.strongholds ?? true; + const advanced = options.advanced ?? options.initial?.advanced ?? true; + assert.ok(tech || strongholds); + if (strongholds) assert.equal(advanced, true); + let game = options.initial ? structuredClone(options.initial) + : createGame('PAIREDCHOAMNEXUSMODULES', newPlayer('richese', 'Richese', 'richese'), advanced, ['choam']); + if (!options.initial) { + joinGame(game, newPlayer('choam', 'CHOAM', 'choam')); + joinGame(game, newPlayer('guild', 'Guild', 'guild')); + joinGame(game, newPlayer('atreides', 'Atreides', 'atreides')); + } + assert.deepEqual(game.players.map(p => p.faction).sort(), ['atreides', 'choam', 'guild', 'richese']); + assert.deepEqual(game.expansions, ['choam']); + assert.equal(game.advanced, advanced); + assert.ok(game.status === 'lobby' || game.status === 'setup' && game.turn === 1 && game.phase === 0 && game.players.every(p => !p.hand.length), 'Use the original authenticated lobby or turn-one undealt setup'); + if (game.status === 'lobby') { + if (!game.nexusCards) game.nexusCards = { cards: null, phase: null }; + 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 }); + for (const p of game.players) if (!p.ready) game = applyAction(game, p.id, { type: 'ready' }); + const initialize = () => initializePairedNexusGameForAudit(game); + game = options.initial ? initialize() : setupEntropy(initialize); + } + assert.ok(pairedNexusModulesProfile(game)); + assert.equal(!!game.techTokens, tech); + assert.equal(!!game.strongholdCards, strongholds); + return game; +} +export function reservePairedChoamBoard(game: Game): void { + for (const p of game.players) { + p.reserves += Object.values(p.forces).reduce((a, b) => a + b, 0); + p.forces = {}; + if (p.elites) { + p.elites.reserves += Object.values(p.elites.forces).reduce((a, b) => a + b, 0); + p.elites.forces = {}; + } + } +} +export function placePairedChoam(game: Game, actor: string, key: string, count: number): void { + const p = pairedChoamPlayer(game, actor); + assert.ok(count >= 0 && p.reserves - (p.elites?.reserves ?? 0) >= count); + p.reserves -= count; + if (count) p.forces[key] = (p.forces[key] ?? 0) + count; +} +export function holdPairedChoamCard(game: Game, actor: string, matches: (card: Card) => boolean): Card { + const p = pairedChoamPlayer(game, actor), held = p.hand.find(matches); + if (held) return held; + const at = game.deck.findIndex(matches); + assert.ok(at >= 0, 'Required original physical card is unavailable'); + assert.ok(p.hand.length < (p.faction === 'choam' ? 5 : 4)); + const card = game.deck.splice(at, 1)[0]; + p.hand.push(card); + return card; +} +/** Real native zero-marker shipment establishes previous-token history. Actual + * first Storm, END Mentat, turn-two worm, reciprocal classic alliance and closing + * Nexus deals precede the controlled movement program. No bank or custody grants. */ +export function createPairedChoamNexusModulesFixture(options: PairedChoamNexusModulesOptions = {}): PairedChoamNexusModulesFixture { + const actions: Step[] = [], staging: string[] = []; + let game = initializePairedChoamModules(options); + const initial = structuredClone(game); + game = advancePairedChoam(game, g => g.status === 'playing', actions); + const afterSetup = structuredClone(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 === 'atreides')!.id; + const location = `habbanya_ridge_sietch:${territory('habbanya_ridge_sietch').sectors[0]}`; + game = advancePairedChoam(game, g => g.phase === 1 && pairedChoamClean(g), actions); + const afterFirstStorm = structuredClone(game); + orderNexusSpice(game, ['land', 'land']); + game = advancePairedChoam(game, g => g.phase === 5 && g.active === richese && pairedChoamClean(g), actions); + reservePairedChoamBoard(game); + const before = structuredClone(game), p = pairedChoamPlayer(game, richese); + const step: Step = { 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 = run(game, step, actions); + game = advancePairedChoam(game, pairedChoamClean, actions); + const firstShipment = { before, step, after: structuredClone(game) }; + game = run(game, { actor: richese, action: { type: 'revealNoField', event: pairedChoamPlayer(game, richese).noFieldEvent, token: pairedChoamPlayer(game, richese).noField!.deployed!.tokenId } }, actions); + game = advancePairedChoam(game, g => g.phase === 8 && pairedChoamClean(g), actions); + reservePairedChoamBoard(game); + placePairedChoam(game, richese, location, 1); + placePairedChoam(game, choam, `arrakeen:${territory('arrakeen').sectors[0]}`, 1); + staging.push('Before genuine first END Mentat: conserve one Richese Habbanya and one CHOAM Arrakeen counter; preserve actual zero-marker history, wallets and first-Storm Tech.'); + let beforeFirstMentat!: Game, firstMentatStep!: Step; + while (game.turn === 1) { + const next = nextPairedChoamStep(game); assert.ok(next); + const previous = structuredClone(game); + game = run(game, next, actions); + if (game.turn === 2) { beforeFirstMentat = previous; firstMentatStep = next; } + } + const afterFirstMentat = structuredClone(game); + game = advancePairedChoam(game, g => g.phase === 1 && pairedChoamClean(g), actions); + orderNexusSpice(game, ['worm', 'land', 'land']); + game = advancePairedChoam(game, g => g.nexus === true && !g.spiceWindow, actions); + game = run(game, { actor: guild, action: { type: 'alliance', target: observer } }, actions); + game = run(game, { actor: observer, action: { type: 'alliance', target: guild } }, actions); + game = advancePairedChoam(game, g => g.nexusCards?.phase?.stage === 'drawing', actions); + for (const [actor, card] of [[richese, 'richese'], [choam, 'choam']] as const) { + const cards = game.nexusCards!.cards!; + assert.ok(cards.deck.includes(card)); + cards.deck = [card, ...cards.deck.filter(c => c !== card)]; + game = run(game, { actor, action: { type: 'nexusCardChoice', turn: game.turn, card: null, choice: 'draw', ownRedraws: 0 } }, actions); + } + staging.push('Conserve all12 deck order; turn-two worm and reciprocal Guild/Atreides alliance cause actual closing Nexus Cunning deals to the two unallied native E2 seats.'); + game = advancePairedChoam(game, g => g.phase === 5 && pairedChoamClean(g), actions); + reservePairedChoamBoard(game); + // Preserve first-deal snapshots; relocate original physical hands only here. + for (const p of game.players) game.deck.push(...p.hand.splice(0)); + const movementStart = structuredClone(game); + game = advancePairedChoam(game, g => g.phase === 5 && g.active === richese && pairedChoamClean(g), actions); + if (options.reserveCap !== undefined) { + assert.ok(Number.isSafeInteger(options.reserveCap) && options.reserveCap >= 0 && options.reserveCap <= 20); + placePairedChoam(game, richese, 'polar_sink:0', 20 - options.reserveCap); + staging.push(`Conserve ${20 - options.reserveCap} Richese counters at Polar Sink, leaving ${options.reserveCap} physical reserves for capped materialization.`); + } + validateNexusCards(game.nexusCards!.cards!, game.players); + return { initial, afterSetup, afterFirstStorm, firstShipment, beforeFirstMentat, firstMentatStep, afterFirstMentat, movementStart, game, richese, choam, guild, observer, location, actions, staging }; +} +export function pairedRicheseCunningRequest(f: PairedChoamNexusModulesFixture, game = f.game): Action { + const p = pairedChoamPlayer(game, f.richese); + return { type: 'ship', noField: p.noField!.tokens.find(t => t.value === 3)!.id, + revealedToken: p.noField!.tokens.find(t => t.value === 5)!.id, + nexus: viewGame(game, f.richese).nexusRicheseCunning!.event, event: p.noFieldEvent, + territory: 'habbanya_ridge_sietch', sector: territory('habbanya_ridge_sietch').sectors[0], allyPayment: 0 }; +} +export function allowPairedChoam(game: Game): Game { + return advancePairedChoam(game, pairedChoamClean); +} +export function revealPairedRichese(game: Game, actor: string): Game { + const p = pairedChoamPlayer(game, actor); + assert.ok(p.noField!.deployed); + return applyAction(game, actor, { type: 'revealNoField', event: p.noFieldEvent, token: p.noField!.deployed.tokenId }); +} diff --git a/tests/fixture-paired-ix-nexus-modules.ts b/tests/fixture-paired-ix-nexus-modules.ts new file mode 100644 index 00000000..36a0fea1 --- /dev/null +++ b/tests/fixture-paired-ix-nexus-modules.ts @@ -0,0 +1,265 @@ +import assert from 'node:assert/strict'; +import { applyAction, createGame, initializePairedNexusGameForAudit, joinGame, newPlayer, type Action, type Game } from '../game/engine'; +import { MOBILE_LOCATION, MOBILE_STRONGHOLD, territory } from '../game/board'; +import type { Card } from '../game/cards'; +import { pairedNexusModulesProfile } from '../game/nexus-module-profile'; +import { ownedTech } from '../game/tech-tokens'; +import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions'; + +export type PairedIxNexusModulesStep = { actor: string; action: Action }; +export type PairedIxNexusModulesOptions = { + initial?: Game; + advanced?: boolean; + tech?: boolean; + strongholds?: boolean; + program?: 'suboids' | 'faceDance'; +}; +export type PairedIxNexusModulesFixture = { + initial: Game; + afterSetup: Game; + afterFirstStorm: Game; + beforeFirstMentat: Game; + afterFirstMentat: Game; + beforeNexusDraw: Game; + afterNexusDraw: Game; + beforeBattle: Game; + game: Game; + ixians: string; + tleilaxu: string; + opponent: string; + partners: [string, string]; + kind: 'arrakeen' | 'sietch_tabr'; + location: string; + replacementSource: string; + battleStep: PairedIxNexusModulesStep; + planActions: PairedIxNexusModulesStep[]; + actions: PairedIxNexusModulesStep[]; + staging: string[]; +}; +export function pairedIxNexusModulesPlayer(game: Game, actor: string) { + const p = game.players.find(player => player.id === actor); + assert.ok(p, `Missing original paired E1 actor ${actor}`); + return p; +} +const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision; + +/** Only real native windows; unsealed plans require explicit human submission. */ +export function nextPairedIxNexusModulesStep(game: Game): PairedIxNexusModulesStep | null { + if (clean(game) && game.nexusCards?.phase?.stage === 'drawing') { + const actor = game.nexusCards.phase.eligible.find(id => !game.nexusCards!.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 && game.decision?.kind === 'techToken') + return { actor: game.decision.player, action: { type: 'decision', token: game.decision.choices[0] } }; + if (!game.phaseOpening && !game.response && game.decision?.kind === 'faceDance') + return { actor: game.decision.player, action: { type: 'decision', reveal: false } }; + return nextStrongholdFactionsNativeStep(game); +} +function run(game: Game, step: PairedIxNexusModulesStep, actions: PairedIxNexusModulesStep[]): Game { + actions.push(structuredClone(step)); + return applyAction(game, step.actor, step.action); +} +export function advancePairedIxNexusModules(state: Game, until: (game: Game) => boolean, actions: PairedIxNexusModulesStep[] = []): Game { + let game = structuredClone(state); + for (let n = 0; n < 2400; n++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished'); + const step = nextPairedIxNexusModulesStep(game); + assert.ok(step, 'The paired native program requires an explicit human plan or Face Dance'); + game = run(game, step, actions); + } + throw Error('Paired E1 module continuation did not reach its native boundary'); +} +function setupEntropy(fn: () => T): T { + const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); + crypto.getRandomValues = (array: V): V => { + assert.ok(array); + new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255); + return array; + }; + try { return fn(); } + finally { + if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); + else Reflect.deleteProperty(crypto, 'getRandomValues'); + } +} +function reserveBoard(game: Game) { + for (const p of game.players) { + p.reserves += Object.values(p.forces).reduce((sum, count) => sum + count, 0); + p.forces = {}; + if (p.elites) { + p.elites.reserves += Object.values(p.elites.forces).reduce((sum, count) => sum + count, 0); + p.elites.forces = {}; + } + } +} +function place(game: Game, actor: string, location: string, total: number, elite = 0) { + const p = pairedIxNexusModulesPlayer(game, actor); + assert.ok(p.reserves >= total && (p.elites?.reserves ?? 0) >= elite && total >= elite); + p.reserves -= total; + p.forces[location] = (p.forces[location] ?? 0) + total; + if (elite) { + p.elites!.reserves -= elite; + p.elites!.forces[location] = (p.elites!.forces[location] ?? 0) + elite; + } +} +function heldCard(game: Game, actor: string, kind: Card['kind'], staging: string[]): string { + const p = pairedIxNexusModulesPlayer(game, actor), held = p.hand.find(c => c.kind === kind); + if (held) return held.id; + const at = game.deck.findIndex(c => c.kind === kind); + assert.ok(at >= 0 && p.hand.length < 4); + const card = game.deck.splice(at, 1)[0]; + p.hand.push(card); + staging.push(`Conserved ${card.id}: remaining original deck → ${actor} hand, after genuine first deal.`); + return card.id; +} +/** Fresh CLI lobby or its original initialized undealt setup; preserve identities, + * native first deal and wallets. Local entropy selects original shuffles only; + * supplied snapshots use their saved native setup and real continuing randomness. */ +export function createPairedIxNexusModulesFixture(options: PairedIxNexusModulesOptions = {}): PairedIxNexusModulesFixture { + 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); + const program = options.program ?? 'suboids'; + assert.ok(tech || strongholds); + assert.ok(!strongholds || advanced); + const actions: PairedIxNexusModulesStep[] = [], staging: string[] = []; + let game = options.initial ? structuredClone(options.initial) + : createGame('PAIREDIXNEXUSMODULES', 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.deepEqual(game.players.map(p => p.faction).sort(), ['emperor', 'guild', 'ixians', 'tleilaxu']); + assert.ok(game.status === 'lobby' || game.status === 'setup' && game.turn === 1 && game.phase === 0 && game.players.every(p => !p.hand.length)); + if (game.status === 'lobby') { + if (!!game.techTokens !== tech) game = run(game, { actor: game.host, action: { type: 'techTokens', enabled: tech } }, actions); + if (!!game.strongholdCards !== strongholds) game = run(game, { actor: game.host, action: { type: 'strongholdCards', enabled: strongholds } }, actions); + // Nexus has no public optional-rules action; admit only its unused CLI component. + game.nexusCards ??= { cards: null, phase: null }; + for (const p of game.players) if (!p.ready) game = run(game, { actor: p.id, action: { type: 'ready' } }, actions); + game = options.initial ? initializePairedNexusGameForAudit(game) : setupEntropy(() => initializePairedNexusGameForAudit(game)); + } + assert.ok(pairedNexusModulesProfile(game)); + assert.equal(!!game.techTokens, tech); + assert.equal(!!game.strongholdCards, strongholds); + const initial = structuredClone(game); + const completeSetup = () => advancePairedIxNexusModules(game, state => state.status === 'playing', actions); + game = options.initial ? completeSetup() : setupEntropy(completeSetup); + const afterSetup = structuredClone(game); + const ixians = game.players.find(p => p.faction === 'ixians')!.id; + const tleilaxu = game.players.find(p => p.faction === 'tleilaxu')!.id; + const classics = game.players.filter(p => p.faction === 'guild' || p.faction === 'emperor').map(p => p.id); + const partners: [string, string] = [classics[0], classics[1]]; + // Conserved land ordering avoids a turn-one worm that cannot supply Nexus cards. + for (let n = 0; n < 2; n++) { + const at = game.spiceDeck.findIndex((card, index) => index >= n && 'territory' in card); + assert.ok(at >= n); + game.spiceDeck.splice(n, 0, game.spiceDeck.splice(at, 1)[0]); + } + game = advancePairedIxNexusModules(game, state => state.phase === 1 && clean(state), actions); + const afterFirstStorm = structuredClone(game); + const opponent = tech ? classics.find(id => ownedTech(game.techTokens, id).length)! : classics[0]; + assert.ok(opponent); + const kind = program === 'faceDance' ? 'sietch_tabr' : 'arrakeen'; + const location = `${kind}:${territory(kind).sectors[0]}`; + game = advancePairedIxNexusModules(game, state => state.phase === 8 && clean(state), actions); + reserveBoard(game); + place(game, ixians, location, 1); + place(game, ixians, MOBILE_LOCATION, 6, 3); + staging.push('Before genuine first END Mentat: conserved Ix counters control the printed site and HMS only; no third Stronghold, wallets or ownership assignment.'); + const beforeFirstMentat = structuredClone(game); + game = advancePairedIxNexusModules(game, state => state.turn === 2, actions); + const afterFirstMentat = structuredClone(game); + game = advancePairedIxNexusModules(game, state => state.phase === 1 && clean(state), actions); + const worm = game.spiceDeck.findIndex(c => 'worm' in c && !c.greatMaker && !c.suppressed); + assert.ok(worm >= 0); + game.spiceDeck.splice(0, 0, game.spiceDeck.splice(worm, 1)[0]); + staging.push('After real second Storm: conserved original worm moved to the next Spice Blow; native worm resolution opens the alliance Nexus.'); + game = advancePairedIxNexusModules(game, state => !!state.nexus && !state.spiceWindow && !state.spiceResolution && clean(state), actions); + game = run(game, { actor: partners[0], action: { type: 'alliance', target: partners[1] } }, actions); + game = run(game, { actor: partners[1], action: { type: 'alliance', target: partners[0] } }, actions); + game = advancePairedIxNexusModules(game, state => state.nexusCards?.phase?.stage === 'drawing', actions); + const cards = game.nexusCards!.cards!; + cards.deck = ['ixians', 'tleilaxu', ...cards.deck.filter(face => face !== 'ixians' && face !== 'tleilaxu')]; + staging.push('Order two conserved undealt Nexus singletons only; both native hands come from real closing Nexus draw actions.'); + const beforeNexusDraw = structuredClone(game); + for (const actor of [ixians, tleilaxu]) game = run(game, { actor, action: { type: 'nexusCardChoice', turn: game.turn, card: null, choice: 'draw', ownRedraws: 0 } }, actions); + game = advancePairedIxNexusModules(game, state => state.nexusCards?.phase?.stage !== 'drawing', actions); + const afterNexusDraw = structuredClone(game); + game = advancePairedIxNexusModules(game, state => state.phase === 5 && clean(state), actions); + reserveBoard(game); + place(game, ixians, location, 6, 3); + if (program === 'suboids') { + place(game, ixians, MOBILE_LOCATION, 4, 2); + place(game, opponent, MOBILE_LOCATION, 2); + } + place(game, opponent, location, 6); + const replacementSource = `arrakeen:${territory('arrakeen').sectors[0]}`; + if (program === 'faceDance') place(game, tleilaxu, replacementSource, 3); + staging.push('Before actual movement completion: conserved subtype forces create native battles, plus original reserve/board Face Dance sources. No phases, plans, payments or revealed flags are staged.'); + const ix = pairedIxNexusModulesPlayer(game, ixians); + const leader = program === 'faceDance' + ? ix.leaders.filter(l => !l.dead && pairedIxNexusModulesPlayer(game, tleilaxu).faceDancers!.some(c => c.leader === l.id && !c.revealed)).sort((a, b) => b.strength - a.strength)[0] + : ix.leaders.filter(l => !l.dead).sort((a, b) => b.strength - a.strength)[0]; + assert.ok(leader, 'The original Face Dancer stock must include a living Ix leader for this human-ready source case'); + const enemy = pairedIxNexusModulesPlayer(game, opponent).leaders.filter(l => !l.dead).sort((a, b) => a.strength - b.strength)[0]; + assert.ok(enemy); + const weapon = program === 'faceDance' ? heldCard(game, ixians, 'projectile', staging) : null; + const defense = program === 'faceDance' ? heldCard(game, ixians, 'shield', staging) : null; + game = advancePairedIxNexusModules(game, state => state.phase === 6 && clean(state), actions); + const beforeBattle = structuredClone(game), actor = game.active!; + assert.ok(actor === ixians || actor === opponent); + const battleStep: PairedIxNexusModulesStep = { actor, action: { type: 'chooseBattle', territory: kind, target: actor === ixians ? opponent : ixians } }; + game = run(game, battleStep, actions); + game = advancePairedIxNexusModules(game, state => !!state.battle && !state.battle.revealed && !nextPairedIxNexusModulesStep(state), actions); + const planActions: PairedIxNexusModulesStep[] = [ + { actor: ixians, action: { type: 'battlePlan', dial: program === 'faceDance' ? 2 : advanced ? 4 : 6, support: advanced ? 1 : 0, leader: leader.id, weapon, defense } }, + { actor: opponent, action: { type: 'battlePlan', dial: 1, support: advanced ? 1 : 0, leader: enemy.id, weapon: null, defense: null } }, + ]; + return { initial, afterSetup, afterFirstStorm, beforeFirstMentat, afterFirstMentat, beforeNexusDraw, afterNexusDraw, beforeBattle, game, ixians, tleilaxu, opponent, partners, kind, location, replacementSource, battleStep, planActions, actions, staging }; +} +export function revealPairedIxNexusModulesBattle(state: Game, plans: PairedIxNexusModulesStep[]): Game { + let game = structuredClone(state); + for (const step of plans) { + game = applyAction(game, step.actor, step.action); + game = advancePairedIxNexusModules(game, g => !g.response && g.decision?.kind !== 'fullPlanOffer'); + } + assert.ok(game.battle?.revealed); + return game; +} +export function finishPairedIxNexusModulesBattle(state: Game): Game { + return advancePairedIxNexusModules(state, g => !g.battle && !g.decision && !g.response && !g.pendingTreacheryDiscard); +} +export function finishPairedIxNexusModulesTurn(state: Game): Game { + return advancePairedIxNexusModules(state, g => g.turn > state.turn); +} +export function choosePairedIxMobileBattle(state: Game, ixians: string, opponent: string): Game { + let game = advancePairedIxNexusModules(state, g => g.phase === 6 && clean(g) && !!g.active); + const actor = game.active!; + assert.ok(actor === ixians || actor === opponent); + game = applyAction(game, actor, { type: 'chooseBattle', territory: MOBILE_STRONGHOLD, target: actor === ixians ? opponent : ixians }); + game = advancePairedIxNexusModules(game, g => g.decision?.kind === 'strongholdCopy' || !!g.battle && !g.battle.revealed && !nextPairedIxNexusModulesStep(g)); + if (game.decision?.kind === 'strongholdCopy') { + assert.equal(game.decision.player, ixians); + game = applyAction(game, ixians, { type: 'decision', event: game.decision.event, stronghold: 'arrakeen' }); + game = advancePairedIxNexusModules(game, g => !!g.battle && !g.battle.revealed && !nextPairedIxNexusModulesStep(g)); + } + return game; +} + +/** Next native turn, conserved Suboids alone make expiry observable by a real + * fractional dial; no phase, Nexus receipt or budget is assigned. */ +export function preparePairedIxExpiryBattle(state: Game, ixians: string, tleilaxu: string): Game { + let game = advancePairedIxNexusModules(state, g => g.phase === 5 && clean(g)); + reserveBoard(game); + const location = `arrakeen:${territory('arrakeen').sectors[0]}`; + place(game, ixians, location, 3); + place(game, tleilaxu, location, 2); + game = advancePairedIxNexusModules(game, g => g.phase === 6 && clean(g)); + const actor = game.active!; + assert.ok(actor === ixians || actor === tleilaxu); + game = applyAction(game, actor, { type: 'chooseBattle', territory: 'arrakeen', target: actor === ixians ? tleilaxu : ixians }); + return advancePairedIxNexusModules(game, g => !!g.battle && !g.battle.revealed && !nextPairedIxNexusModulesStep(g)); +} diff --git a/tests/paired-choam-nexus-modules-runtime.test.ts b/tests/paired-choam-nexus-modules-runtime.test.ts new file mode 100644 index 00000000..98727411 --- /dev/null +++ b/tests/paired-choam-nexus-modules-runtime.test.ts @@ -0,0 +1,296 @@ +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 { gameDistance, splitLocation, TERRITORIES, 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 { quoteStrongholdFactionsBattle } from './fixture-stronghold-factions'; +import { orderNexusSpice } from './fixture-nexus-cards'; +import { + advancePairedChoam, allowPairedChoam, createPairedChoamNexusModulesFixture, + holdPairedChoamCard, initializePairedChoamModules, pairedChoamClean, + pairedChoamPlayer as player, pairedRicheseCunningRequest, placePairedChoam, + revealPairedRichese, type PairedChoamNexusModulesFixture, +} from './fixture-paired-choam-nexus-modules'; + +function reject(game: Game, actor: string, action: Action): void { + const before = structuredClone(game); + assert.throws(() => applyAction(game, actor, action)); + assert.deepEqual(game, before, 'An illegal native choice cannot consume original money, tokens, cards or counters'); +} +function physicalCards(game: Game): string[] { + return [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []), + ...(game.ixSetupCards ?? []), ...(game.ixAuction?.cards ?? []), + ...(game.auction?.cards.slice(game.auction.index) ?? []), ...game.players.flatMap(p => p.hand)].map(c => c.id).sort(); +} +function conserved(game: Game, original: Game): void { + assert.deepEqual(physicalCards(game), physicalCards(original), 'Original E2 deck and native cache have one physical custody location per card'); + for (const p of game.players) { + assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0), 20, p.faction); + assert.deepEqual(p.noField?.tokens, player(original, p.id).noField?.tokens, 'Physical marker identities and printed values never change'); + } +} +function assertPair(f: PairedChoamNexusModulesFixture, after: Game, faceup: number): void { + const before = player(f.game, f.richese), p = player(after, f.richese); + const request = pairedRicheseCunningRequest(f); + assert.equal(p.spice, before.spice - 1, 'The original one-marker Stronghold tariff is paid, not the eight-force tariff'); + assert.equal(player(after, f.guild).spice, player(f.game, f.guild).spice + 1, 'The actual native Guild receives the one-spice invoice'); + assert.equal(p.reserves, before.reserves - faceup); + assert.equal(p.forces[f.location] ?? 0, faceup); + assert.equal(p.noField!.deployed!.tokenId, request.noField); + assert.equal(p.noField!.lastShipped, request.noField); + assert.equal(presenceAt(p, 'habbanya_ridge_sietch'), faceup + 1); + assert.equal(after.nexusCards!.cards!.hands[f.richese], null); + assert.equal(after.nexusRicheseCunningLast!.stage, 'shipped'); + conserved(after, f.afterSetup); +} + +for (const modules of [ + { advanced: false, tech: true, strongholds: false }, + { advanced: true, tech: true, strongholds: false }, + { advanced: true, tech: false, strongholds: true }, + { advanced: true, tech: true, strongholds: true }, +]) void test(`paired E2 ${modules.advanced ? 'Advanced' : 'Basic'} Tech=${modules.tech} SH=${modules.strongholds}: real two-token shipment, one tariff and once-only phase-end Heighliners`, () => { + const f = createPairedChoamNexusModulesFixture(modules), request = pairedRicheseCunningRequest(f); + 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); + const previous = player(f.game, f.richese).noField!.tokens.find(t => t.value === 0)!; + assert.equal(player(f.game, f.richese).noField!.lastShipped, previous.id, 'Genuine first-turn shipment, not a staged last-used stamp'); + 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 }); + const declared = applyAction(f.game, f.richese, request); + const shipped = allowPairedChoam(declared); + assertPair(f, shipped, 5); + if (modules.strongholds) { + assert.equal(f.afterFirstMentat.strongholdCards!.owners.habbanya_ridge_sietch, f.richese); + assert.equal(shipped.strongholdCards!.owners.habbanya_ridge_sietch, f.richese, 'The card is retained even after controlled board relocation'); + assert.equal(f.afterFirstMentat.strongholdCards!.owners.arrakeen, f.choam); + } + if (modules.tech) { + const token = shipped.techTokens!.heighliners; + assert.ok(token.owner); + const amount = ownedTech(f.game.techTokens, token.owner).length; + assert.equal(token.triggeredTurn, shipped.turn); + assert.equal(token.spice, amount); + assert.equal(player(shipped, token.owner).spice, player(f.game, token.owner).spice + (token.owner === f.richese ? -1 : token.owner === f.guild ? 1 : 0), 'Industry accrues without immediate wallet credit'); + reject(shipped, f.richese, { type: 'ship', amount: 1, territory: 'arrakeen', sector: territory('arrakeen').sectors[0] }); + let another = shipped; + if (shipped.movementRemaining?.includes(f.choam)) { + const atChoam = advancePairedChoam(shipped, g => g.phase === 5 && g.active === f.choam && pairedChoamClean(g)); + another = allowPairedChoam(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, 'Neither a second paid shipment nor a rejected repeated shipment accrues industry again'); + const credit = quotePhaseResources(another).credits.find(c => c.kind === 'tech' && c.token === 'heighliners'); + assert.ok(credit); + assert.equal(credit.player, token.owner); + assert.equal(credit.amount, amount); + const collection = quoteSpiceCollection(another).receipts.find(r => r.player === token.owner); + const ended = advancePairedChoam(another, g => g.phase >= 6 && pairedChoamClean(g)); + 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); + assertPair(f, shipped, 5); + conserved(ended, f.afterSetup); + } +}); + +void test('paired E2 materializes only available physical reserves, and voluntary revelation retains previous-marker restriction on the next native shipment', () => { + const f = createPairedChoamNexusModulesFixture({ advanced: false, strongholds: false, reserveCap: 2 }); + const shipped = allowPairedChoam(applyAction(f.game, f.richese, pairedRicheseCunningRequest(f))); + assertPair(f, shipped, 2); + const revealed = revealPairedRichese(shipped, f.richese); + assert.equal(player(revealed, f.richese).noField!.deployed, null); + assert.equal(player(revealed, f.richese).forces[f.location], 2, 'Concealed three cannot manufacture counters when reserves are empty'); + assert.equal(player(revealed, f.richese).reserves, 0); + const previous = player(revealed, f.richese).noField!.lastShipped!; + let next = advancePairedChoam(revealed, g => g.turn === 3 && g.phase === 1 && pairedChoamClean(g)); + // Deck ordering remains physical; prevent a fresh worm from changing this native lifecycle program. + orderNexusSpice(next, ['land', 'land']); + next = advancePairedChoam(next, g => g.turn === 3 && g.phase === 5 && g.active === f.richese && pairedChoamClean(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(t => t.value === 0)!; + const accepted = allowPairedChoam(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!.lastShipped, zero.id); + assert.equal(player(accepted, f.richese).noField!.deployed!.tokenId, zero.id); + assert.equal(player(accepted, f.richese).spice, player(next, f.richese).spice - 2, 'Polar Sink uses the actual ordinary one-marker non-Stronghold price'); + conserved(accepted, f.afterSetup); +}); + +void test('paired E2 Karama-stopped pair spends Nexus but neither invoice, counters, markers nor Heighliners accrual', () => { + const f = createPairedChoamNexusModulesFixture(); + const karama = holdPairedChoamCard(f.game, f.guild, c => c.kind === 'special' && c.effect === 'karama'); + const before = structuredClone(f.game), request = pairedRicheseCunningRequest(f); + const pending = applyAction(f.game, f.richese, request); + assert.equal(pending.response?.kind, 'richeseNoField'); + assert.equal(pending.pendingShipment?.cost, 1); + assert.equal(pending.pendingShipment?.amount, 1); + assert.equal(pending.nexusCards!.cards!.hands[f.richese], null); + const stopped = allowPairedChoam(applyAction(pending, f.guild, { type: 'card', mode: 'cancel', card: karama.id })); + assert.equal(stopped.nexusRicheseCunningLast!.stage, 'stopped'); + for (const p of before.players) assert.equal(player(stopped, p.id).spice, p.spice, 'No paid leg exists for the stopped shipment'); + 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(player(stopped, f.richese).noField, player(before, f.richese).noField); + assert.deepEqual(stopped.techTokens, before.techTokens); + assert.equal(player(stopped, f.richese).shipped, false); + reject(stopped, f.richese, request); + const ordinary = allowPairedChoam(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); + conserved(ordinary, f.afterSetup); +}); + +void test('paired CHOAM own Cunning consumes one original physical fuel card through the real printed Kulon response and enables its extra territory', () => { + const f = createPairedChoamNexusModulesFixture(); + let game = advancePairedChoam(f.movementStart, g => g.phase === 5 && g.active === f.choam && pairedChoamClean(g)); + const card = holdPairedChoamCard(game, f.choam, c => c.kind === 'projectile'); + holdPairedChoamCard(game, f.guild, c => c.kind === 'special' && c.effect === 'karama'); + const offer = viewGame(game, f.choam).choamWorthless!.plays.find(p => p.source === 'nexus' && p.card.id === card.id && p.effect === 'kulon'); + assert.ok(offer && offer.source === 'nexus' && !offer.blocked); + const request: Action = { type: 'card', mode: 'choam', card: card.id, effect: 'kulon', nexus: offer.event }; + const before = structuredClone(game); + game = applyAction(game, f.choam, request); + assert.equal(game.response?.kind, 'choamWorthless'); + assert.equal(game.nexusCards!.cards!.hands[f.choam], null); + assert.ok(player(game, f.choam).hand.some(c => c.id === card.id), 'Fuel remains physical until the original response resolves'); + game = allowPairedChoam(game); + assert.equal(game.nexusChoamLast!.stage, 'complete'); + assert.equal(game.discard.filter(c => c.id === card.id).length, 1); + assert.equal(game.choamMovement!.bonus, 1); + assert.equal(viewGame(game, f.choam).choamMovementBonus, 1); + for (const p of before.players) assert.equal(player(game, p.id).spice, p.spice); + reject(game, f.choam, request); + const keys = TERRITORIES.flatMap(t => t.sectors.map(s => `${t.id}:${s}`)); + let destination: string | undefined; + const source = keys.find(from => { + const origin = splitLocation(from); + if (origin.sector === game.storm || territory(origin.territory).type === 'stronghold' || origin.territory === 'polar_sink') return false; + destination = keys.find(to => splitLocation(to).sector !== game.storm && + gameDistance(game, from, to, k => splitLocation(k).sector === game.storm) === 2); + return !!destination; + }); + assert.ok(source && destination, 'An actual clear two-territory route is available for Kulon'); + placePairedChoam(game, f.choam, source, 1); + const move: Action = { type: 'move', from: source, amount: 1, ...splitLocation(destination) }; + placePairedChoam(before, f.choam, source, 1); + reject(before, f.choam, move); + const moved = allowPairedChoam(applyAction(game, f.choam, move)); + assert.equal(player(moved, f.choam).forces[destination], 1); + assert.equal(player(moved, f.choam).forces[source] ?? 0, 0); + assert.equal(player(moved, f.choam).moved, 1); + conserved(moved, f.afterSetup); + conserved(game, f.afterSetup); +}); + +void test('paired Advanced Habbanya retained effect wins an ordinary tied battle after native voluntary marker reveal, without entering mixed No-Field plans', () => { + const f = createPairedChoamNexusModulesFixture(); + let game = allowPairedChoam(applyAction(f.game, f.richese, pairedRicheseCunningRequest(f))); + game = revealPairedRichese(game, f.richese); + assert.equal(player(game, f.richese).noField!.deployed, null); + assert.equal(player(game, f.richese).forces[f.location], 8); + placePairedChoam(game, f.guild, f.location, 6); + game = advancePairedChoam(game, g => g.phase === 6 && pairedChoamClean(g)); + assert.ok(game.active === f.richese || game.active === f.guild); + game = applyAction(game, game.active!, { type: 'chooseBattle', territory: 'habbanya_ridge_sietch', target: game.active === f.richese ? f.guild : f.richese }); + game = advancePairedChoam(game, g => pairedChoamClean(g) && !!g.battle && (!g.battle.preLeader || g.battle.preLeader.closed) && !g.battle.preparation); + const richeseLeader = player(game, f.richese).leaders.filter(l => !l.dead).sort((a, b) => b.strength - a.strength)[0]; + const guildLeader = player(game, f.guild).leaders.filter(l => !l.dead).sort((a, b) => b.strength - a.strength)[0]; + assert.ok(richeseLeader && guildLeader); + const richeseDial = Math.max(0, guildLeader.strength - richeseLeader.strength) + 1; + const guildDial = Math.max(0, richeseLeader.strength - guildLeader.strength) + 1; + assert.ok(richeseDial <= 8 && guildDial <= 6); + for (const [actor, leader, dial] of [[f.richese, richeseLeader.id, richeseDial], [f.guild, guildLeader.id, guildDial]] as const) { + game = applyAction(game, actor, { type: 'battlePlan', leader, dial, support: dial, weapon: null, defense: null }); + game = allowPairedChoam(game); + } + assert.ok(game.battle?.revealed); + const revealed = structuredClone(game), quote = quoteStrongholdFactionsBattle(game); + assert.equal(richeseDial + richeseLeader.strength, guildDial + guildLeader.strength); + assert.equal(viewGame(revealed, f.richese).battle!.strongholdEffects[f.richese], 'habbanya_ridge_sietch', 'Tie benefit is attributed to the actual retained card holder and committed plan'); + assert.equal(quote.winner, f.richese); + const payment = quote.payments.find(p => p.player === f.richese)!; + assert.equal(payment.ownPayment, richeseDial); + assert.equal(payment.bankSupport, 0, 'Habbanya grants tie priority, not Arrakeen support'); + game = advancePairedChoam(game, g => !g.battle && !g.response && !g.decision && !g.pendingTreacheryDiscard); + assert.equal(game.lastBattleContext!.winner, f.richese); + assert.equal(player(game, f.richese).tanks, richeseDial); + assert.equal(player(game, f.richese).forces[f.location], 8 - richeseDial); + assert.equal(player(game, f.guild).tanks, 6); + assert.equal(game.strongholdCards!.owners.habbanya_ridge_sietch, f.richese); + const collection = quoteSpiceCollection(game).receipts; + for (const p of revealed.players) { + const paid = quote.payments.find(r => r.player === p.id)?.ownPayment ?? 0; + const choam = quote.choamIncome?.owner === p.id ? quote.choamIncome.amount : 0; + const city = collection.find(r => r.player === p.id); + assert.equal(player(game, p.id).spice, p.spice - paid + choam + (city?.strongholds ?? 0) + (city?.collected ?? 0), 'Plan support and native CHOAM bank legs are separate from city Collection'); + } + for (const token of ownedTech(revealed.techTokens, f.guild)) assert.equal(game.techTokens![token].owner, f.richese); + conserved(game, f.afterSetup); +}); + +void test('paired original undealt CLI setup preserves authenticated actors and first deal while continuing real closing Nexus participation', () => { + const lobby = createGame('CHOAMCLIAUDIT', newPlayer('auth-richese', 'R', 'richese'), true, ['choam']); + joinGame(lobby, newPlayer('auth-choam', 'C', 'choam')); + joinGame(lobby, newPlayer('auth-guild', 'G', 'guild')); + joinGame(lobby, newPlayer('auth-atreides', 'A', 'atreides')); + const setup = initializePairedChoamModules({ initial: lobby }); + const original = structuredClone(setup); + const f = createPairedChoamNexusModulesFixture({ initial: setup }); + assert.deepEqual(setup, original); + assert.deepEqual(f.initial, setup, 'Supplied undealt initializer is not restarted or redealt'); + assert.equal(f.richese, 'auth-richese'); + assert.equal(f.choam, 'auth-choam'); + assert.equal(f.guild, 'auth-guild'); + assert.equal(f.observer, 'auth-atreides'); + const live = viewGame(f.game, f.richese); + live.players.find(p => p.id === f.richese)!.bot = 'Hard'; + const request = botActions(live)[0]; + assert.ok(request?.type === 'ship' && request.noField && request.revealedToken); + const completed = allowPairedChoam(applyAction(f.game, f.richese, request)); + assert.equal(completed.nexusRicheseCunningLast!.stage, 'shipped'); + assert.equal(completed.nexusCards!.cards!.hands[f.richese], null); + conserved(completed, f.afterSetup); +}); + +void test('paired Basic Tech ordinary battle uses revealed physical pools and no Advanced support payment or CHOAM bank-support income', () => { + const f = createPairedChoamNexusModulesFixture({ advanced: false, strongholds: false }); + let game = allowPairedChoam(applyAction(f.game, f.richese, pairedRicheseCunningRequest(f))); + game = revealPairedRichese(game, f.richese); + placePairedChoam(game, f.guild, f.location, 6); + game = advancePairedChoam(game, g => g.phase === 6 && pairedChoamClean(g)); + assert.ok(game.active === f.richese || game.active === f.guild); + game = applyAction(game, game.active!, { type: 'chooseBattle', territory: 'habbanya_ridge_sietch', target: game.active === f.richese ? f.guild : f.richese }); + game = advancePairedChoam(game, g => pairedChoamClean(g) && !!g.battle && (!g.battle.preLeader || g.battle.preLeader.closed) && !g.battle.preparation); + const winner = player(game, f.richese).leaders.filter(l => !l.dead).sort((a, b) => b.strength - a.strength)[0]; + const loser = player(game, f.guild).leaders.filter(l => !l.dead).sort((a, b) => a.strength - b.strength)[0]; + assert.ok(winner && loser); + assert.ok(winner.strength + 3 > loser.strength); + for (const [actor, leader, dial] of [[f.richese, winner.id, 3], [f.guild, loser.id, 0]] as const) { + game = applyAction(game, actor, { type: 'battlePlan', leader, dial, support: 0, weapon: null, defense: null }); + game = allowPairedChoam(game); + } + assert.ok(game.battle?.revealed); + const revealed = structuredClone(game), quote = quoteStrongholdFactionsBattle(game); + assert.equal(quote.winner, f.richese); + assert.equal(quote.choamIncome, null); + for (const payment of quote.payments) { + assert.equal(payment.ownPayment, 0); + assert.equal(payment.bankSupport, 0); + assert.equal(payment.cost, 0, 'Basic dials never become Advanced support invoices'); + } + game = advancePairedChoam(game, g => !g.battle && !g.response && !g.decision && !g.pendingTreacheryDiscard); + assert.equal(player(game, f.richese).tanks, 3); + assert.equal(player(game, f.richese).forces[f.location], 5); + assert.equal(player(game, f.guild).tanks, 6); + for (const p of revealed.players) assert.equal(player(game, p.id).spice, p.spice); + for (const token of ownedTech(revealed.techTokens, f.guild)) assert.equal(game.techTokens![token].owner, f.richese); + conserved(game, f.afterSetup); +}); diff --git a/tests/paired-ix-nexus-modules-runtime.test.ts b/tests/paired-ix-nexus-modules-runtime.test.ts new file mode 100644 index 00000000..82040d2a --- /dev/null +++ b/tests/paired-ix-nexus-modules-runtime.test.ts @@ -0,0 +1,284 @@ +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 { MOBILE_STRONGHOLD, territory } from '../game/board'; +import { quoteSpiceCollection } from '../game/board-resolution-quote'; +import { nexusFaceDancersAction } from '../game/nexus-tleilaxu-options'; +import { ownedTech } from '../game/tech-tokens'; +import { strongholdBenefit, strongholdControllers } from '../game/stronghold-cards'; +import { quoteStrongholdFactionsBattle } from './fixture-stronghold-factions'; +import { + advancePairedIxNexusModules, choosePairedIxMobileBattle, createPairedIxNexusModulesFixture, + finishPairedIxNexusModulesBattle, finishPairedIxNexusModulesTurn, pairedIxNexusModulesPlayer as player, + preparePairedIxExpiryBattle, revealPairedIxNexusModulesBattle, type PairedIxNexusModulesFixture, + type PairedIxNexusModulesStep, +} from './fixture-paired-ix-nexus-modules'; + +function reject(game: Game, actor: string, action: Action) { + const before = structuredClone(game); + assert.throws(() => applyAction(game, actor, action)); + assert.deepEqual(game, before, 'An illegal consumer action cannot spend the original singleton, forces or spice'); +} +function inventory(game: Game) { + return [...game.deck, ...game.discard, ...(game.ixSetupCards ?? []), ...(game.ixAuction?.cards ?? []), + ...(game.auction?.cards.slice(game.auction.index) ?? []), ...game.players.flatMap(p => p.hand)].map(c => c.id).sort(); +} +function custody(game: Game, original: Game) { + assert.deepEqual(inventory(game), inventory(original)); + 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, count] of Object.entries(p.elites.forces)) assert.ok(count <= (p.forces[key] ?? 0)); + } + } + const traitors = [...(game.traitorReserve ?? []), ...game.players.flatMap(p => [...p.traitors, ...(p.faceDancers ?? []).map(c => c.leader)])].sort(); + const originalTraitors = [...(original.traitorReserve ?? []), ...original.players.flatMap(p => [...p.traitors, ...(p.faceDancers ?? []).map(c => c.leader)])].sort(); + assert.deepEqual(traitors, originalTraitors); + assert.equal(new Set(traitors).size, traitors.length); +} +function reward(f: PairedIxNexusModulesFixture, game: Game) { + assert.equal(game.lastBattleContext!.winner, f.ixians); + if (f.game.techTokens) { + const token = ownedTech(f.game.techTokens, f.opponent); + assert.equal(token.length, 1); + assert.equal(game.techTokens![token[0]].owner, f.ixians); + assert.equal(game.techTokens![token[0]].spice, f.game.techTokens![token[0]].spice); + assert.equal(ownedTech(game.techTokens, f.opponent).length, 0); + } +} +function money(revealed: Game, settled: Game) { + const quote = quoteStrongholdFactionsBattle(revealed); + const collection = settled.phase === 7 && !settled.phaseOpening ? quoteSpiceCollection(settled).receipts : []; + for (const p of revealed.players) { + const payment = quote.payments.find(r => r.player === p.id)?.ownPayment ?? 0; + const income = quote.strongholdIncome.filter(r => r.player === p.id).reduce((sum, r) => sum + r.amount, 0); + const bounty = quote.bounty?.player === p.id ? quote.bounty.amount : 0; + const city = collection.filter(r => r.player === p.id).reduce((sum, r) => sum + r.collected + r.strongholds, 0); + assert.equal(player(settled, p.id).spice, p.spice - payment + income + bounty + city, p.faction); + } + return quote; +} +function useSuboids(f: PairedIxNexusModulesFixture): Game { + const offer = viewGame(f.game, f.ixians).nexusSuboids!.offer!; + assert.equal(offer.blocked, null); + reject(f.game, f.tleilaxu, { type: 'nexusSuboids', event: offer.event }); + const game = applyAction(f.game, f.ixians, { type: 'nexusSuboids', event: offer.event }); + assert.equal(game.nexusCards!.cards!.hands[f.ixians], null); + assert.equal(game.nexusCards!.cards!.discard.filter(face => face === 'ixians').length, 1); + assert.equal(player(game, f.ixians).spice, player(f.game, f.ixians).spice); + assert.deepEqual(player(game, f.ixians).forces, player(f.game, f.ixians).forces); + assert.deepEqual(player(game, f.ixians).elites, player(f.game, f.ixians).elites); + reject(game, f.ixians, { type: 'nexusSuboids', event: offer.event }); + return game; +} + +for (const mode of [ + { advanced: false, tech: true, strongholds: false }, + { advanced: true, tech: true, strongholds: false }, + { advanced: true, tech: false, strongholds: true }, + { advanced: true, tech: true, strongholds: true }, +]) void test(`paired E1 ${JSON.stringify(mode)}: original Nx Cunning, exact cyborg losses, equal substitution and held-site support`, () => { + const f = createPairedIxNexusModulesFixture(mode); + assert.equal(f.afterNexusDraw.nexusCards!.cards!.hands[f.ixians], 'ixians'); + assert.equal(f.afterNexusDraw.nexusCards!.cards!.hands[f.tleilaxu], 'tleilaxu'); + assert.equal(player(f.afterNexusDraw, f.partners[0]).ally, f.partners[1]); + assert.equal(player(f.afterNexusDraw, f.ixians).ally, null); + if (mode.strongholds) { + assert.equal(f.afterFirstMentat.strongholdCards!.owners.arrakeen, f.ixians); + assert.equal(f.afterFirstMentat.strongholdCards!.owners[MOBILE_STRONGHOLD], f.ixians); + } + const game = useSuboids(f); + const revealed = revealPairedIxNexusModulesBattle(game, f.planActions); + const quote = quoteStrongholdFactionsBattle(revealed); + assert.equal(quote.winner, f.ixians); + assert.equal(quote.casualties!.forces.normalFixedHalf, false); + assert.equal(quote.casualties!.forces.normalFreeSupport, true); + const payment = quote.payments.find(r => r.player === f.ixians)!; + if (mode.advanced) { + assert.equal(payment.bankSupport, mode.strongholds ? 1 : 0); + assert.equal(payment.ownPayment, mode.strongholds ? 0 : 1); + } else { + assert.deepEqual(quote.payments, [], 'Basic battles create no spice-support payment receipts'); + } + let losses = advancePairedIxNexusModules(revealed, g => g.decision?.kind === 'battleLosses' || g.decision?.kind === 'ixSubstitution'); + if (losses.decision?.kind === 'battleLosses') { + const choice = losses.decision.options.findIndex(o => o.normal === 0 && o.elite === 3); + assert.ok(choice >= 0, 'The actual committed dial can lose exactly the three original cyborgs'); + losses = applyAction(losses, f.ixians, { type: 'decision', choice }); + } + const pending = advancePairedIxNexusModules(losses, g => g.decision?.kind === 'ixSubstitution'); + assert.equal(player(pending, f.ixians).forces[f.location], 3); + assert.equal(player(pending, f.ixians).elites!.forces[f.location] ?? 0, 0); + assert.equal(player(pending, f.ixians).tanks, player(revealed, f.ixians).tanks + 3); + assert.equal(player(pending, f.ixians).elites!.tanks, 3); + assert.equal(player(pending, f.ixians).spice, player(revealed, f.ixians).spice - (mode.advanced ? payment.ownPayment : 0)); + assert.deepEqual(pending.techTokens, revealed.techTokens, 'Native substitution precedes the actual loser Tech award'); + reject(pending, f.ixians, { type: 'decision', sources: { [f.location]: 3 }, recover: { [f.location]: 4 } }); + const exchanged = applyAction(pending, f.ixians, { type: 'decision', sources: { [f.location]: 3 }, recover: { [f.location]: 3 } }); + const settled = finishPairedIxNexusModulesBattle(exchanged); + 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, 0); + assert.equal(player(settled, f.opponent).tanks, player(revealed, f.opponent).tanks + 6); + money(revealed, settled); reward(f, settled); custody(settled, f.afterSetup); + + // A second real battle in the original turn uses the spent card's effect. + const mobile = choosePairedIxMobileBattle(settled, f.ixians, f.opponent); + assert.equal(viewGame(mobile, f.ixians).nexusSuboids!.active, true); + if (mode.strongholds) assert.equal(viewGame(mobile, f.ixians).battle!.strongholdEffects[f.ixians], 'arrakeen'); + const leader = player(mobile, f.ixians).leaders.filter(l => !l.dead && !l.usedAt).sort((a, b) => b.strength - a.strength)[0]; + const enemy = player(mobile, f.opponent).leaders.filter(l => !l.dead && !l.usedAt).sort((a, b) => a.strength - b.strength)[0]; + const plans: PairedIxNexusModulesStep[] = [ + { actor: f.ixians, action: { type: 'battlePlan', dial: 3, support: mode.advanced ? 1 : 0, leader: leader.id, weapon: null, defense: null } }, + { actor: f.opponent, action: { type: 'battlePlan', dial: 0, support: 0, leader: enemy.id, weapon: null, defense: null } }, + ]; + const second = revealPairedIxNexusModulesBattle(mobile, plans), secondQuote = quoteStrongholdFactionsBattle(second); + assert.equal(secondQuote.casualties!.forces.normalFixedHalf, false); + assert.equal(secondQuote.casualties!.forces.normalFreeSupport, true); + if (mode.advanced) { + const own = secondQuote.payments.find(r => r.player === f.ixians)!; + assert.equal(own.bankSupport, mode.strongholds ? 1 : 0); + assert.equal(own.ownPayment, mode.strongholds ? 0 : 1); + } else assert.deepEqual(secondQuote.payments, []); + let secondLosses = advancePairedIxNexusModules(second, g => g.decision?.kind === 'battleLosses' || g.decision?.kind === 'ixSubstitution'); + if (secondLosses.decision?.kind === 'battleLosses') { + const choice = secondLosses.decision.options.findIndex(o => o.normal === 1 && o.elite === 1); + assert.ok(choice >= 0); + secondLosses = applyAction(secondLosses, f.ixians, { type: 'decision', choice }); + } + const secondSettled = finishPairedIxNexusModulesBattle(secondLosses); + money(second, secondSettled); custody(secondSettled, f.afterSetup); + const nextTurn = finishPairedIxNexusModulesTurn(secondSettled); + assert.equal(viewGame(nextTurn, f.ixians).nexusSuboids!.active, false); + const expired = preparePairedIxExpiryBattle(nextTurn, f.ixians, f.tleilaxu); + const expiredLeader = player(expired, f.ixians).leaders.find(l => !l.dead)!; + reject(expired, f.ixians, { type: 'battlePlan', dial: 3, support: 0, leader: expiredLeader.id, weapon: null, defense: null }); + const sealed = applyAction(expired, f.ixians, { type: 'battlePlan', dial: 1.5, support: 0, leader: expiredLeader.id, weapon: null, defense: null }); + assert.equal(sealed.battle!.plans[f.ixians].dial, 1.5); + custody(sealed, f.afterSetup); +}); + +void test('native paired Cunning requires an own unsealed plan and grants full unsupported Suboid casualties, not force manufacture', () => { + const f = createPairedIxNexusModulesFixture({ advanced: true, strongholds: false }); + const offer = viewGame(f.game, f.ixians).nexusSuboids!.offer!; + const sealed = applyAction(f.game, f.ixians, f.planActions[0].action); + reject(sealed, f.ixians, { type: 'nexusSuboids', event: offer.event }); + const plans = structuredClone(f.planActions); + plans[0].action.dial = 3; plans[0].action.support = 0; + const empowered = revealPairedIxNexusModulesBattle(useSuboids(f), plans); + const ordinary = revealPairedIxNexusModulesBattle(f.game, plans); + const full = quoteStrongholdFactionsBattle(empowered), half = quoteStrongholdFactionsBattle(ordinary); + assert.ok(full.casualties!.options.some(o => o.normal === 3 && o.elite === 0)); + assert.ok(!half.casualties!.options.some(o => o.normal === 3 && o.elite === 0)); + assert.equal(full.payments.find(r => r.player === f.ixians)!.ownPayment, 0); + assert.deepEqual(player(empowered, f.ixians).forces, player(ordinary, f.ixians).forces); + assert.deepEqual(player(empowered, f.ixians).elites, player(ordinary, f.ixians).elites); + const losses = advancePairedIxNexusModules(empowered, g => g.decision?.kind === 'battleLosses'); + assert.equal(losses.decision!.kind, 'battleLosses'); + if (losses.decision?.kind !== 'battleLosses') throw Error('Missing native Suboid casualty decision'); + const choice = losses.decision.options.findIndex(o => o.normal === 3 && o.elite === 0); + assert.ok(choice >= 0); + const paid = applyAction(losses, f.ixians, { type: 'decision', choice }); + const settled = finishPairedIxNexusModulesBattle(paid); + 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(empowered, f.ixians).tanks + 3); + assert.equal(player(settled, f.ixians).elites!.tanks, player(empowered, f.ixians).elites!.tanks); + money(empowered, settled); reward(f, settled); custody(settled, f.afterSetup); +}); + +void test('paired native Face Dance preserves original winner rewards, cards and Tech, then claims Stronghold at actual END Mentat; partial-stock Cunning draws before retiring originals', () => { + const f = createPairedIxNexusModulesFixture({ program: 'faceDance', advanced: true, tech: true, strongholds: true }); + const revealed = revealPairedIxNexusModulesBattle(f.game, f.planActions); + const pending = advancePairedIxNexusModules(revealed, g => g.decision?.kind === 'faceDance'); + const quote = money(revealed, pending); + assert.deepEqual(quote.strongholdIncome, [{ player: f.ixians, amount: 1 }]); + assert.equal(quote.bounty!.player, f.ixians); + reward(f, pending); + const winner = player(pending, f.ixians), survivorCount = winner.forces[f.location]; + const elite = winner.elites!.forces[f.location] ?? 0; + for (const id of [f.planActions[0].action.weapon, f.planActions[0].action.defense]) assert.ok(winner.hand.some(c => c.id === id)); + const dancerBefore = structuredClone(player(pending, f.tleilaxu).faceDancers!); + assert.ok(dancerBefore.every(c => !c.revealed)); + reject(pending, f.tleilaxu, { type: 'decision', reveal: true, sources: { reserves: survivorCount + 1 }, sector: territory(f.kind).sectors[0] }); + const after = applyAction(pending, f.tleilaxu, { type: 'decision', reveal: true, sources: { [f.replacementSource]: 2, reserves: survivorCount - 2 }, sector: territory(f.kind).sectors[0] }); + const 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 + survivorCount); + assert.equal(returned.elites!.reserves, winner.elites!.reserves + elite); + assert.equal(returned.tanks, winner.tanks); assert.equal(returned.elites!.tanks, winner.elites!.tanks); + assert.equal(returned.spice, winner.spice); assert.deepEqual(returned.hand, winner.hand); + assert.deepEqual(after.techTokens, pending.techTokens); + assert.equal(replacement.forces[f.location], survivorCount); + assert.equal(replacement.forces[f.replacementSource], 1); + assert.equal(replacement.reserves, player(pending, f.tleilaxu).reserves - survivorCount + 2); + assert.equal(strongholdControllers(after.players, true)[f.kind], f.tleilaxu); + assert.equal(strongholdBenefit(after.strongholdCards, f.ixians, f.kind), f.kind); + assert.equal(strongholdBenefit(after.strongholdCards, f.tleilaxu, f.kind), null); + assert.equal(returned.leaders.find(l => l.id === f.planActions[0].action.leader)!.dead, true); + const revealedStock = replacement.faceDancers!.filter(c => c.revealed); + assert.equal(revealedStock.length, 1, 'Partial stock comes only from the actual successful Face Dance'); + const untouched = replacement.faceDancers!.filter(c => !c.revealed); + assert.deepEqual(untouched, dancerBefore.filter(c => c.leader !== revealedStock[0].leader)); + const beforeDraw = [...after.traitorReserve!], allowance = replacement.faceDancerReplacedTurn; + const view = viewGame(after, f.tleilaxu), offer = view.nexusTleilaxu!.cunning!; + const action = nexusFaceDancersAction(view, offer.event); assert.ok(action); + reject(after, f.tleilaxu, { ...action, leaders: [revealedStock[0].leader] }); + const replaced = applyAction(after, f.tleilaxu, action); + const finalStock = player(replaced, f.tleilaxu).faceDancers!; + assert.deepEqual(finalStock.filter(c => untouched.some(old => old.leader === c.leader)), untouched); + assert.deepEqual(finalStock.filter(c => !untouched.some(old => old.leader === c.leader)), [{ leader: beforeDraw[0], revealed: false }]); + assert.ok(!revealedStock.some(c => c.leader === beforeDraw[0]), 'Set-aside originals cannot be their own immediate draw'); + assert.deepEqual([...replaced.traitorReserve!].sort(), [...beforeDraw.slice(1), revealedStock[0].leader].sort()); + assert.equal(player(replaced, f.tleilaxu).faceDancerReplacedTurn, allowance); + assert.equal(replaced.nexusCards!.cards!.hands[f.tleilaxu], null); + assert.equal(replaced.nexusCards!.cards!.discard.filter(face => face === 'tleilaxu').length, 1); + assert.deepEqual(replaced.techTokens, after.techTokens); + reject(replaced, f.tleilaxu, action); + custody(replaced, f.afterSetup); + const mentat = advancePairedIxNexusModules(replaced, g => g.phase === 8 && !g.phaseOpening && !g.response && !g.decision); + const ordinary = applyAction(mentat, f.tleilaxu, { type: 'replaceFaceDancer', leader: untouched[0].leader }); + const settled = advancePairedIxNexusModules(ordinary, g => !g.response && !g.decision); + assert.equal(player(settled, f.tleilaxu).faceDancerReplacedTurn, settled.turn); + reject(settled, f.tleilaxu, { type: 'replaceFaceDancer', leader: player(settled, f.tleilaxu).faceDancers![0].leader }); + const claimed = finishPairedIxNexusModulesTurn(settled); + assert.equal(claimed.strongholdCards!.owners[f.kind], f.tleilaxu); + assert.deepEqual(claimed.techTokens, after.techTokens); + custody(claimed, f.afterSetup); +}); + +void test('representative native bot Face Dance consumes only actual paired reserve/board sources', () => { + const f = createPairedIxNexusModulesFixture({ program: 'faceDance' }); + const pending = advancePairedIxNexusModules(revealPairedIxNexusModulesBattle(f.game, f.planActions), g => g.decision?.kind === 'faceDance'); + const view = viewGame(pending, f.tleilaxu); + view.players.find(p => p.id === f.tleilaxu)!.bot = 'Easy'; + const action = botActions(view).find(a => a.type === 'decision'); assert.ok(action); + assert.equal(action.reveal, true); + const after = applyAction(pending, f.tleilaxu, action); + assert.equal(player(after, f.ixians).forces[f.location] ?? 0, 0); + assert.equal(player(after, f.tleilaxu).forces[f.location], player(pending, f.ixians).forces[f.location]); + assert.deepEqual(after.techTokens, pending.techTokens); + custody(after, f.afterSetup); +}); + +void test('original authenticated lobby and undealt CLI setup continue to actor-bound real Nexus Cunning without redealing', () => { + const lobby = createGame('PAIREDIXAUTH', newPlayer('authenticated-ix', 'Ix', 'ixians'), true, ['ix']); + for (const [id, faction] of [['authenticated-tl', 'tleilaxu'], ['authenticated-guild', 'guild'], ['authenticated-emperor', 'emperor']] as const) + joinGame(lobby, newPlayer(id, faction, faction)); + const first = createPairedIxNexusModulesFixture({ initial: lobby, tech: true, strongholds: false }); + const continued = createPairedIxNexusModulesFixture({ initial: first.initial }); + assert.deepEqual(player(continued.afterSetup, first.ixians).hand, player(first.afterSetup, first.ixians).hand, + 'The original Ix setup offer and actual first deal are continued, not replaced'); + const empowered = useSuboids(continued); + const revealed = revealPairedIxNexusModulesBattle(empowered, continued.planActions); + assert.equal(quoteStrongholdFactionsBattle(revealed).winner, 'authenticated-ix'); + assert.equal(revealed.nexusSuboidHistory![0].owner, 'authenticated-ix'); + assert.equal(player(revealed, 'authenticated-tl').ally, null); + custody(revealed, continued.afterSetup); +}); diff --git a/tools/faction-games.ts b/tools/faction-games.ts index 07d5b5b5..7f319419 100644 --- a/tools/faction-games.ts +++ b/tools/faction-games.ts @@ -4,7 +4,7 @@ import { readFileSync, statSync, writeFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { parseArgs } from 'node:util'; -import { classicNexusModulesProfile } from '../game/nexus-module-profile'; +import { classicNexusModulesProfile, pairedNexusModulesProfile } from '../game/nexus-module-profile'; import { nativeFactionTechProfile } from '../game/faction-module-profile'; import { botActions } from '../game/bots'; import { treacheryDeck } from '../game/cards'; @@ -41,7 +41,8 @@ const DEFAULT_SEED = 20_260_926; const DEFAULT_MAX_ACTIONS = 3_500; 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'; + | 'factions-tech' | 'stronghold-factions-tech' | 'nexus-tech' | 'nexus-stronghold' | 'nexus-stronghold-tech' + | 'paired-nexus-tech' | 'paired-nexus-stronghold' | 'paired-nexus-stronghold-tech'; type Rules = 'basic' | 'advanced'; type Scenario = { @@ -153,9 +154,13 @@ const CLASSIC_NEXUS_MODULE_SCENARIOS: readonly Scenario[] = (profile === 'nexus-stronghold' || scenario.roster.length >= 3)) .map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 800 + band * 100 + index }))); const nexusModuleProfile = (profile: string) => - profile === 'nexus-tech' || profile === 'nexus-stronghold' || profile === 'nexus-stronghold-tech'; -const nexusTechProfile = (profile: string) => profile === 'nexus-tech' || profile === 'nexus-stronghold-tech'; -const nexusStrongholdProfile = (profile: string) => profile === 'nexus-stronghold' || profile === 'nexus-stronghold-tech'; + profile === 'nexus-tech' || profile === 'nexus-stronghold' || profile === 'nexus-stronghold-tech' || pairedNexusModuleProfile(profile); +const nexusTechProfile = (profile: string) => profile === 'nexus-tech' || profile === 'nexus-stronghold-tech' || + profile === 'paired-nexus-tech' || profile === 'paired-nexus-stronghold-tech'; +const nexusStrongholdProfile = (profile: string) => profile === 'nexus-stronghold' || profile === 'nexus-stronghold-tech' || + profile === 'paired-nexus-stronghold' || profile === 'paired-nexus-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[] = [ { profile: 'choam-roster' as const, expansions: ['choam'], roster: ['choam', 'richese', 'emperor', 'guild', 'harkonnen', 'fremen'] as FactionId[] }, { profile: 'ecaz-roster' as const, expansions: ['ecaz'], roster: ['moritani', 'ecaz', 'atreides', 'beneGesserit', 'harkonnen', 'guild'] as FactionId[] }, @@ -215,6 +220,13 @@ const PAIRED_NEXUS_SCENARIOS: readonly Scenario[] = EXPANSION_ROSTER_SCENARIOS profile: scenario.profile.replace('-roster', '-nexus') as Profile, ordinal: scenario.ordinal + 30, })); +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 + .filter(scenario => scenario.profile !== 'ecaz-nexus' && + (profile === 'paired-nexus-tech' || scenario.rules === 'advanced') && + (profile === 'paired-nexus-stronghold' || scenario.roster.length >= 3)) + .map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 1100 + band * 100 + index }))); const COMBINED_NEXUS_SCENARIOS: readonly Scenario[] = [ ...SCENARIOS.filter(scenario => scenario.profile === 'combined') .map(scenario => ({ ...scenario, profile: 'combined-nexus' as const, ordinal: scenario.ordinal + 124 })), @@ -441,7 +453,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-tech|nexus-stronghold|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-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.' + @@ -454,6 +466,7 @@ function usage() { + '\nAll three combined skill/module profiles additionally admit standalone native Ecaz OR Moritani with classic opponents and the exact ecaz33 deck. Original five-disc Ecaz/Duke assignment and Advanced Harkonnen exclusion remain; Advanced Moritani retains its original non-Harkonnen skill-first assassination and normal-traitor forfeiture. E3 pairs, mixed-family rosters, allied Occupy skills and other overlays stay guarded.' + '\nFactions-tech uses native E1/E2 families or standalone Ecaz OR Moritani plus classics, three-through-six seats, Basic/Advanced, original selected decks and three Tech Tokens without Skills. Stronghold-factions-tech is its Advanced six-card Stronghold composition. Native Tech setup, real payments, typed battle rewards and selected Ecaz lead attribution remain; Basic odd Occupy, Richese cache/No-Field rulings, other modules and public starts stay guarded.' + '\nNexus-tech preserves classic/base33/all12 Nexus and original Tech3..6 in Basic/Advanced. Nexus-stronghold adds six original Stronghold Cards in Advanced2..6; nexus-stronghold-tech requires3..6. Original closing Nexus deals, actual free/paid source identity, phase-end Tech, held support and postbattle card/reward windows remain. Skills, Homeworlds, expansions, other overlays, unresolved effects, public starts and played-game conversion stay separate.' + + '\nPaired-nexus-tech adds original Tech to both Ixians+Tleilaxu OR CHOAM+Richese and classic opponents, one selected deck, three-through-six seats, Basic/Advanced. Paired-nexus-stronghold is Advanced2..6; paired-nexus-stronghold-tech is Advanced3..6. Native Cunning, real closing deals, typed forces/physical Face Dancers/No-Fields, original industry/rewards and held effects remain. No mixed families, E3, Skills, other overlays, new rulings, public starts or save conversion.' ); } @@ -508,6 +521,10 @@ 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)) { + const family = scenario.expansions[0] === 'ix' ? 'ixians-tleilaxu' : 'choam-richese'; + return `${scenario.profile}-${family}-${scenario.roster.length}-${scenario.rules}`; + } return scenario.profile === 'base' || scenario.profile === 'homeworld' || scenario.profile === 'nexus' || scenario.profile === 'homeworld-nexus' || scenario.profile === 'combined-nexus' || scenario.profile === 'combined-homeworld-nexus' || scenario.profile === 'choam-roster' || scenario.profile === 'ecaz-roster' || scenario.profile === 'ecaz-treachery' || scenario.profile === 'ecaz-occupy' || scenario.profile === 'moritani-assassinate' || scenario.profile === 'ix-roster' || scenario.profile === 'choam-nexus' || scenario.profile === 'ecaz-nexus' || scenario.profile === 'ix-nexus' || skillProfile(scenario.profile) || nexusModuleProfile(scenario.profile) ? `${scenario.profile}-${scenario.roster.length}-${scenario.rules}` @@ -570,7 +587,7 @@ function freshGame(scenario: Scenario) { game.nexusCards = { cards: null, phase: null }; if (scenario.profile === 'combined-nexus' || scenario.profile === 'combined-homeworld-nexus') return initializeCombinedNexusGameForAudit(game, scenario.profile === 'combined-homeworld-nexus'); - if (scenario.profile === 'choam-nexus' || scenario.profile === 'ecaz-nexus' || scenario.profile === 'ix-nexus') + if (scenario.profile === 'choam-nexus' || scenario.profile === 'ecaz-nexus' || scenario.profile === 'ix-nexus' || pairedNexusModuleProfile(scenario.profile)) return initializePairedNexusGameForAudit(game); if (scenario.profile === 'ecaz-treachery') return initializeEcazTreacheryGameForAudit(game); @@ -629,11 +646,11 @@ function resumedGame(path: string) { game.discoveries || game.discoveryStash || game.greatMaker || - (game.techTokens && !composedSkills && !nativeFactionTechProfile(game) && !classicNexusModulesProfile(game)) || + (game.techTokens && !composedSkills && !nativeFactionTechProfile(game) && !classicNexusModulesProfile(game) && !pairedNexusModulesProfile(game)) || 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].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].find( (candidate) => (candidate.profile === 'homeworld' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.homeworlds && (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 && @@ -862,6 +879,7 @@ async function main() { const resume = values.resume ? resumedGame(values.resume) : null; const requestedSamples = profile === 'homeworld' || profile === 'nexus' || profile === 'homeworld-nexus' ? MODULE_SCENARIOS + : pairedNexusModuleProfile(profile) ? PAIRED_NEXUS_MODULE_SCENARIOS : nexusModuleProfile(profile) ? CLASSIC_NEXUS_MODULE_SCENARIOS : profile === 'choam-roster' || profile === 'ecaz-roster' || profile === 'ix-roster' ? EXPANSION_ROSTER_SCENARIOS : profile === 'ecaz-treachery' ? ECAZ_TREACHERY_SCENARIOS diff --git a/tools/prototype-room.ts b/tools/prototype-room.ts index 5e382f64..e8f14ed2 100644 --- a/tools/prototype-room.ts +++ b/tools/prototype-room.ts @@ -1,3 +1,4 @@ +import { pairedNexusModulesProfile } from '../game/nexus-module-profile'; import type { DatabaseSync } from 'node:sqlite'; import { initializeIxGameForAudit, @@ -7,6 +8,7 @@ import { initializeFactionExpansionsGameForAudit, initializeStrongholdFactionsGameForAudit, initializeNexusGameForAudit, + initializePairedNexusGameForAudit, initializeMoritaniAssassinateGameForAudit, initializeEcazTreacheryGameForAudit, initializeEcazOccupyGameForAudit, @@ -94,6 +96,7 @@ export function startPrototypeRoom( throw new Error('--mentat-question requires the leader-skills profile.'); if (options.ecazTreachery) initial.ecazTreachery = true; if (options.mentatQuestion) initial.mentatQuestionPreview = true; + if (profile === 'nexus' && !initial.nexusCards) initial.nexusCards = { cards: null, phase: null }; const game = profile === 'ix' ? initializeIxGameForAudit(initial) @@ -142,10 +145,9 @@ export function startPrototypeRoom( : profile === 'factions' ? initializeFactionExpansionsGameForAudit(initial) : profile === 'nexus' - ? initializeNexusGameForAudit({ - ...initial, - nexusCards: initial.nexusCards ?? { cards: null, phase: null }, - }) + ? pairedNexusModulesProfile(initial) + ? initializePairedNexusGameForAudit(initial) + : initializeNexusGameForAudit(initial) : profile === 'leader-skills' ? initializeLeaderSkillsGameForAudit(initial) : profile === 'banker-income' diff --git a/tools/start-prototype.ts b/tools/start-prototype.ts index 93b5fe94..5b06b1ec 100644 --- a/tools/start-prototype.ts +++ b/tools/start-prototype.ts @@ -31,6 +31,7 @@ async function main() { '\nThese skill/module entries additionally preserve standalone Ecaz OR Moritani ecaz33/all14 profiles. Tech is Basic/Advanced three-through-six seats; Strongholds is Advanced two-through-six. Native Ecaz five-disc/Duke assignment and Advanced Harkonnen exclusion, plus original Advanced Moritani skill-first assassination/normal-call forfeiture, remain. E3 pairs, mixed families, allied Occupy skills and other overlays are not opened.', '\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.', ); return; }