diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 489b8c38..148fc9c8 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -1,6 +1,6 @@ # Current development status -Updated 6 October 2026. **The full twelve-faction goal is unfinished. Basic +Updated 8 October 2026. **The full twelve-faction goal is unfinished. Basic is playable and the six classic factions have a user-authorized Advanced preview. Expansion starts and full rules/publication acceptance remain gated.** @@ -17,17 +17,26 @@ open mode gates. ## Current checkpoint and work -**In progress — original native Homeworld modules.** Parent integrates the -existing E1/E2 and standalone E3 roster/deck predicates with original Skills, -Discovery or both, optional Tech/Advanced Strongholds and no Nexus. Disjoint -native E1/E2 and standalone E3 programmes are authored/frozen. Eight original -native setups initially reject before play; admission exposes the genuine -Ixian pre-training card stage and a real physical-Suk null-quote crash. -Exact pre-placement nullable custody and source-qualified residual Cyborg -forwarding are repaired; focused native/visitor normal/trained rescue and -existing non-native Face Dance offer suppression pass. The authored remaining -programmes still have failures, including real pending CHOAM low-income gates; -they are not a verified native checkpoint. No ruling/public/deployment gate changes. +**Native Homeworld modules — bounded Development Verified.** Existing E1/E2 and +standalone Ecaz OR Moritani/classic setup now compose Skills, Discovery or +both with optional Tech/Advanced Strongholds, no Nexus. Genuine Ixian +pre-training nullable custody and physical-Suk residual Cyborg forwarding +repair reproduced setup/crash cases. New35 cases and affected180/180 across +19 files pass; final repair union46/46, types/lint/build pass. +Original six-seat samples14/19 complete; all12 new families run144 original +Advanced three-seat samples, initially134 complete. Eight CHOAM ruling +captures remain; two genuine Ecaz placement deadlocks were repaired by +using the engine/UI's existing base-board stronghold enumeration. All four +policies advance both exact captures, which finish102/124/2 and21/29/0 +further accepted/attempted/JSON actions. Source batches and prior five +six-seat ruling captures remain explicit; no uninterrupted144/final-tree +certificate is claimed. Actual human390px `4AG8J2ZX`v74 chooses its original +Ixian card/training and ships one Suboid/one Cyborg to Kaitain for2, retaining +Ix9/3, visitor1/1 and original HMS3/3. `TYH3JS3C`v56 submits actual native +Moritani Suk rescue: Grumman14, own Tanks0, defeated Guild Tanks2; original +Collection follows. Both tabs closed; throwaway smoke scripts removed. +[Exact native scope](HOMEWORLD_RUNTIME_IMPLEMENTATION.md#native-optional-module-compositions--8-october-2026). +No public, ruling, comprehensive-assurance or protected-deployment gate changes. **Classic Homeworld optional modules — bounded Development Verified.** Fresh classic Basic/Advanced2–6 now composes Skills, Discovery or both with diff --git a/docs/DISCOVERY_PROTOTYPE.md b/docs/DISCOVERY_PROTOTYPE.md index 2190c479..f6cde428 100644 --- a/docs/DISCOVERY_PROTOTYPE.md +++ b/docs/DISCOVERY_PROTOTYPE.md @@ -5,6 +5,20 @@ partial rules coverage. It connects physical components, real actions, private controls, AI choices and saved continuation. Normal public Discovery and Advanced start gates, and the publication gate, remain closed. +## Native Homeworld composition — 8 October 2026 + +Fresh supported E1/E2 and standalone Ecaz OR Moritani/classic Discovery +entries retain original Homeworlds, optionally all14 Skills through the +existing `leader-skills --discoveries` entry, Tech3+ and Advanced Strongholds2+. +Existing Basic/Advanced roster and family-deck boundaries remain. Actual +parent entry, source-qualified nested invoices, capped native No-Fields and +native/visitor/nested casualty aftermath reuse original consumers. Ambassador +placement still targets only base-board strongholds, not revealed Discovery +strongholds; minimal legal AI now respects that existing engine/UI boundary. +See [original programmes and preserved captures](HOMEWORLD_RUNTIME_IMPLEMENTATION.md#native-optional-module-compositions--8-october-2026). +Nexus with Homeworlds, unsupported E3 pairs/mixed families, pending +interpretations, public starts and full-mode acceptance remain separate. + ## Classic Homeworld composition — 6 October 2026 Fresh private classic Discovery setup retains Homeworlds in Basic/Advanced diff --git a/docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md b/docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md index 56795002..7a2bc97b 100644 --- a/docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md +++ b/docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md @@ -6,6 +6,93 @@ disabled.** It does not certify Homeworld battles, occupation, card effects or complete Advanced/expansion games. The earlier component checkpoint remains in [HOMEWORLD_IMPLEMENTATION.md](HOMEWORLD_IMPLEMENTATION.md). +## Native optional-module compositions — 8 October 2026 + +The existing fresh private entries now preserve Homeworlds with Skills, +Discovery or both for supported native E1/E2 single/paired rosters and +Advanced distinct-family decks, or standalone Ecaz **OR** Moritani with +classic opponents. Basic/Advanced2–6; original unused Tech3+ and Advanced +Strongholds2+ remain optional. Original roster/deck, Duke and assassination +exclusions remain; no Nexus, unsupported E3 pairs/mixed families, unrelated +previews, public starts or played-game conversion is admitted. + +Authorized revision physical21–23 permits the variants together/separately +irrespective of factions. Existing native predicates and initializers remain +the single setup path. Nullable Homeworld custody now covers the original +Ixian `skillTreachery` card choice before training/force placement. Actual +normal/trained native/visitor Suk rescue forwards residual Cyborg Tanks from +the same physical quote into optional equal Suboid substitution, fixing a +reproduced null-quote crash without restoring rescued counters to the Tanks. +Native saved forces stay at the original home; the Imperial external split +remains the implementation interpretation below, not ordinary revival. +Non-native Homeworld winners retain the existing Face Dance offer prohibition. +Original CHOAM payer/support, Richese caps and revealed nested invoice, +Tleilaxu own leader revival, Ecaz Arrakis-only Occupy, Moritani assassination/ +skill return and native-world winner Tech timing remain distinct mechanics. + +Thirty-five new semantic cases and the affected19-file union180/180 pass. +After the subsequently discovered Ambassador bot and natural-Storm fixture +repairs, the final four-file union46/46, types/lint and build pass. +The old classic invasion fixture now explicitly allows the original Guild +interception decision: a real Easy-AI Karama cancellation had removed the +army/battle under test. A preserved reproduction and80 original repaired +invasion boundaries prove the fixture change, not a new shipment rule. + +The runner adds twelve `native-homeworld-{skills,discovery,discovery-skills}` +families with optional `-tech`, `-stronghold`, `-stronghold-tech` at stable +bands7600–8700; existing classic names/seeds6400–7500 are unchanged. +Original six-seat `native-homeworld-skills` samples:14/19 complete, +18693 accepted/18718 attempted actions and501 JSON continuations among those +completed games, on unchanged tree +`fc792b6089e8d3a17e661daf5a76b9148ad45692c3dfafb2d9481520822d6a31`. +Five original captures stop at existing CHOAM low-opening-income, exhausted +Richese cache or Basic odd Ecaz Occupy ruling guards; none is bypassed. + +All twelve families then run twelve original Advanced three-seat samples +each:134/144 initially complete,34730 accepted/34853 attempted actions/ +867 JSON among completed games, on unchanged tree +`3a99f592826e5476825d43330397c7fdca6582157f2bc05600f24a7b7919105f`. +Eight captures retain the existing CHOAM ruling guard. Two actual Ecaz +placement deadlocks exposed a bot enumerating revealed Discovery strongholds +although the original engine/UI permit only base-board strongholds. +The bot now uses that same base-board enumeration; every difficulty legally +declines on both preserved exhausted-destination states and advances to +Shipment. Those exact original captures complete102/124/2 and21/29/0 further +accepted/attempted/JSON actions on unchanged tree +`f80a99ba1799254ea93bd3eed6cc64ece7d95195d5503f115c0f570cf9e1828a`. +This is136 completed three-seat games **across repaired continuations**, +not144 uninterrupted passes or a final-tree certificate. Existing rejected +competing Ambassador-arrival candidates remain visible in these reports. +Reports/captures are private `/tmp/dune-native-homeworld-*-20261008` and +`/tmp/dune-native-homeworld-skills-first-games-20261007`; no original game, +native custody or pending ruling is reset. + +Human390px QA uses two original authenticated HTTP lobbies and the existing +private CLI, with no redeal, held-card/force injection or outcome lottery. +`4AG8J2ZX`v9 starts original Ixian `skillTreachery`: the human chooses its +original Shield, then Prana-Bindu Adept on C’tair Pilru before original +Traitors/native placement. In actual turn-one Shipment the human declares +one Suboid plus one Cyborg from Ix to Kaitain for2 spice. Atv74 Ix retains +9 Suboids/3 Cyborgs, the original Kaitain visitor group is1/1, the separate +HMS army remains3/3 and the Ixian wallet is7. Original Skills/Discovery/ +Tech/Stronghold custody remains. + +`TYH3JS3C` uses original standalone Advanced Moritani Homeworld/Skills +setup without Discovery. The human keeps its actually offered Suk Graduate +on Lupino Ord. A genuine two-force Guild invasion and original1-supported +native plan produce the native Moritani win. The human saves the actual +single ordinary casualty through the owned Suk panel. Atv56 Grumman still +has14 native forces, Moritani Tanks0, Guild Tanks2 and no visitors remain. +The original Grumman Collection decision follows; no native force is moved +to another home and support/plans/casualties are not replayed. +Both actual choice screenshots, fresh accessibility/resulting state and +document width390 were observed. These are working-tree controls, not a +pushed revision or deployed acceptance claim. + +This bounded composition is Development Verified, not complete faction/ +module coverage, all combinations, comprehensive assurance, calibration or +protected deployment. + ## Classic optional-module compositions — 6 October 2026 The fresh classic Homeworld prototypes now compose **Skills, Discovery or diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 8a03560b..e4bb7541 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,34 @@ # Dune implementation status +## 8 October 2026 — Native Homeworld optional modules + +Original supported native E1/E2 and standalone Ecaz OR Moritani/classic +Homeworld entries now retain Skills, Discovery or both with optional +Tech3+/Advanced Strongholds2+. Existing family decks, native setup and +pending rulings remain. Exact Ixian pre-training nullable custody and +same-quote residual Cyborg Tanks repair reproduced admission/Suk crashes. +Native CHOAM invoices/support, Richese caps/nested invoices, Tleilaxu +revival, Ecaz Arrakis Occupy and Moritani skill-first assassination retain +original consumers. Twelve native runner families preserve old names/seeds. + +New35 cases; affected180/180 across19 files, final repair46/46 and +types/lint/build pass. Original six-seat14/19 completions and all12-family +Advanced three-seat134/144 initial completions retain exact source trees +and ruling captures. Two actual Ambassador bot deadlocks finish from their +preserved states after base-board target enumeration is repaired; every +difficulty legally advances both captures. Not an uninterrupted144 pass or +a final-tree certificate. A genuine Guild cancellation and natural-Storm +fixture boundary are repaired with explicit legal programme choices, not +altered shipment/storm rules. + +Human390px original HTTP/private CLI `4AG8J2ZX`v74 chooses its original +Ixian starting card/training and typed paid native invasion; `TYH3JS3C`v56 +chooses native Moritani Suk rescue after a genuine defensive Homeworld win. +Original armies, Tanks, skill custody and Collection continuation remain. +Both tabs closed; temporary scripts removed. +[Source-qualified evidence and limits](HOMEWORLD_RUNTIME_IMPLEMENTATION.md#native-optional-module-compositions--8-october-2026). +No full-mode, comprehensive assurance, calibration or protected-deployment claim. + ## 6 October 2026 — Classic Homeworld optional modules Original classic Homeworld Skills/Discovery families now compose either or diff --git a/docs/LEADER_SKILLS_RUNTIME.md b/docs/LEADER_SKILLS_RUNTIME.md index f42a225b..8e2461d9 100644 --- a/docs/LEADER_SKILLS_RUNTIME.md +++ b/docs/LEADER_SKILLS_RUNTIME.md @@ -5,6 +5,19 @@ Normal mode starts and publication remain gated. The [source contract](LEADER_SK contains all fourteen physical cards; the [capture interpretation](LEADER_SKILLS_CAPTURE.md) records the fresh publisher/designer search requested by the user. +## Native Homeworld composition — 8 October 2026 + +Fresh supported native E1/E2 or standalone Ecaz OR Moritani/classic entries +now retain original Homeworlds with all14 Skills, optional Discovery7/8, +Tech3+ and Advanced Strongholds2+. Existing roster/deck, Duke and +assassination restrictions remain. The original Ixian card-before-training +stage precedes native placement; physical Suk rescue forwards only residual +Cyborg Tanks into equal Suboid substitution. Native-world rescue, genuine +Tleilaxu own revival and Moritani death/replacement use existing custody. +See [source-qualified native proof](HOMEWORLD_RUNTIME_IMPLEMENTATION.md#native-optional-module-compositions--8-october-2026). +Nexus with Homeworlds, unsupported E3 pairs/mixed families, pending +interpretations, public starts and full-mode acceptance remain separate. + ## Classic Homeworld composition — 6 October 2026 Fresh Basic/Advanced two–six unique base-faction private skill prototypes diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index d4882a4f..a33ef6de 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -165,6 +165,21 @@ reveal history and the log now name the sourced forfeiture. Exceptional leader custody and the independent private-step UX/public-preview gates are unchanged. See [the cutover and Advanced sequence](MORITANI_ASSASSINATE_LEADERS.md#authority-and-advanced-duration-cutover). +## Native Homeworld module composition — 8 October 2026 + +Authorized supplied revision physical21–23 permits Homeworlds, Skills, +Discovery, Tech and Strongholds together/separately irrespective of faction +selection. The bounded fresh native composition therefore reuses supported +E1/E2 and standalone Ecaz OR Moritani/classic setup predicates, not a new +rule model. Original skill-before-faction ordering applies to typed Suk and +residual Cyborg substitution; Homeworlds remain outside Arrakis Occupy and +Intrusion. Existing CHOAM low-opening-income, Basic odd Ecaz and exhausted +Richese-cache guards remain visible in original game captures. The external +Imperial Suk split is still an implementation interpretation, not ordinary +revival placement or a new user/publisher ruling. Native Face Dance, +Diplomat retreat and other pending interpretations remain separate. +[Exact scope and evidence](HOMEWORLD_RUNTIME_IMPLEMENTATION.md#native-optional-module-compositions--8-october-2026). + ## Advanced Homeworld occupation source cutover — 5 October 2026 The authorized supplied revision, physical **page22**, qualifies an occupier diff --git a/docs/SUK_GRADUATE_RULES.md b/docs/SUK_GRADUATE_RULES.md index ebff74a2..2c4c4627 100644 --- a/docs/SUK_GRADUATE_RULES.md +++ b/docs/SUK_GRADUATE_RULES.md @@ -4,6 +4,21 @@ bands to real battle resolution, player choices, AI and saved continuation. Complete module, faction and publication gates remain closed. +## Native Homeworld composition — 8 October 2026 + +Supported fresh native E1/E2 and standalone Ecaz OR Moritani/classic +Homeworld skill entries retain the original rescue bands with optional +Discovery/Tech/Advanced Strongholds. Native saves remain at that home; +external Imperial return allocation retains the implementation interpretation +below. The same source-qualified physical quote now exposes residual Cyborg +Tanks for equal Suboid substitution after rescue, repairing a reproduced +automatic-normal-Suk null-quote crash. Rescued Cyborgs are not available for +that substitution. Existing non-native Homeworld Face Dance suppression, +support, skill-first assassination and original cleanup remain unchanged. +See [native runtime proof](HOMEWORLD_RUNTIME_IMPLEMENTATION.md#native-optional-module-compositions--8-october-2026). +Advanced Atreides/KH, Diplomat retreat, Emperor Face Dance allocation, Nexus +with Homeworlds and other pending interpretations remain guarded. + ## Classic Homeworld composition — 6 October 2026 The fresh classic Homeworld/Skills prototype preserves the printed rescue diff --git a/game/bots.ts b/game/bots.ts index 895224cd..1696558c 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -1762,7 +1762,7 @@ function policyActions(g: GameView): Action[] { ambassadors.nextCost <= (me.spice ?? 0) ) { const token = ambassadors.tokens.find((t) => t.zone === 'supply'); - const target = gameTerritories(g).find( + const target = TERRITORIES.find( (t) => t.type === 'stronghold' && t.id !== MOBILE_STRONGHOLD && diff --git a/game/bureaucrat-payment.ts b/game/bureaucrat-payment.ts index 95caffba..b5af02cd 100644 --- a/game/bureaucrat-payment.ts +++ b/game/bureaucrat-payment.ts @@ -1,4 +1,4 @@ -import { classicHomeworldLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, noOtherLeaderSkillModules, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile, type LeaderSkillProfile } from './leader-skill-profile'; +import { homeworldLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, noOtherLeaderSkillModules, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile, type LeaderSkillProfile } from './leader-skill-profile'; import { classicDiscoveryLeaderSkillsProfile } from './discovery-module-profile'; export type BureaucratPaymentKind = 'auction' | 'shipment' | 'bribe' | 'revival'; export type BureaucratPaymentSource = { @@ -39,7 +39,7 @@ export function bureaucratPaymentModeSupported(game: LeaderSkillProfile): boolea return (noOtherLeaderSkillModules(game) && (!game.expansions.length || (game.expansions.length === 1 && game.expansions[0] === 'choam') || - nativeExpansionLeaderSkillsProfile(game))) || classicHomeworldLeaderSkillsProfile(game) || classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game) || strongholdLeaderSkillsProfile(game) || classicNexusLeaderSkillsProfile(game) || standaloneE3NexusLeaderSkillsProfile(game) || classicDiscoveryLeaderSkillsProfile(game) || nativeDiscoveryLeaderSkillsProfile(game); + nativeExpansionLeaderSkillsProfile(game))) || homeworldLeaderSkillsProfile(game) || classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game) || strongholdLeaderSkillsProfile(game) || classicNexusLeaderSkillsProfile(game) || standaloneE3NexusLeaderSkillsProfile(game) || classicDiscoveryLeaderSkillsProfile(game) || nativeDiscoveryLeaderSkillsProfile(game); } export function bureaucratUsed( used: readonly BureaucratPaymentUse[], diff --git a/game/discovery-module-profile.ts b/game/discovery-module-profile.ts index a4368bfe..f285c9e4 100644 --- a/game/discovery-module-profile.ts +++ b/game/discovery-module-profile.ts @@ -9,34 +9,17 @@ export function discoveryModuleProfile(game: FactionModuleProfile): boolean { discoveryModeSupported(game); } -/** Original classic Homeworld/Discovery, optionally Tech/Advanced Strongholds. */ -export function classicHomeworldDiscoveryProfile(game: FactionModuleProfile): boolean { - if (!game.homeworlds || typeof game.advanced !== 'boolean' || game.discoveryEnabled !== true || - game.expansions.length || game.players.length < 2 || game.players.length > 6 || - game.leaderSkills || game.nexusCards || (game.techTokens && game.players.length < 3) || - (game.strongholdCards && !game.advanced) || - game.ecazTreachery || game.semutaPreview || game.advancedPreview || game.kullPreview || - game.nexusKullPreview || game.guildBetrayalPreview || game.richeseBetrayalPreview || - game.nexusIxianReplacementPreview || game.nexusIxianBetrayalPreview || - game.nexusHarkonnenBetrayalPreview) return false; - for (let i = 0; i < game.players.length; i++) { - const player = game.players[i]; - for (let j = 0; j < i; j++) if (game.players[j].faction === player.faction) return false; - let classic = false; - for (const faction of FACTIONS) - if (faction.id === player.faction) { classic = faction.expansion === 'base'; break; } - if (!classic) return false; - } - return true; +/** Original Homeworld/Discovery under the same classic/native roster contract. */ +export function homeworldDiscoveryProfile(game: FactionModuleProfile): boolean { + return !!game.homeworlds && discoveryModeSupported(game); } /** The same roster/module contract during play; live Discovery frames are expected. */ export function discoveryModeSupported(game: FactionModuleProfile): boolean { - if (classicHomeworldDiscoveryProfile(game)) return true; if (typeof game.advanced !== 'boolean' || game.discoveryEnabled !== true || game.players.length < 2 || game.players.length > 6 || (game.techTokens && game.players.length < 3) || game.expansions.length > 2 || - (game.strongholdCards && !game.advanced) || game.leaderSkills || game.homeworlds || game.nexusCards || + (game.strongholdCards && !game.advanced) || game.leaderSkills || game.nexusCards || game.ecazTreachery || game.semutaPreview || game.advancedPreview || game.kullPreview || game.nexusKullPreview || game.guildBetrayalPreview || game.richeseBetrayalPreview || diff --git a/game/engine.ts b/game/engine.ts index 01062cfd..e31d7e9f 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1,7 +1,7 @@ import { classicNexusModulesProfile, pairedNexusModulesProfile } from './nexus-module-profile'; import { nativeFactionTechProfile } from './faction-module-profile'; -import { classicDiscoveryLeaderSkillsProfile, classicDiscoveryNexusProfile, classicHomeworldDiscoveryProfile, discoveryModeSupported, discoveryModuleProfile, pairedDiscoveryNexusProfile } from './discovery-module-profile'; -import { advancedMoritaniLeaderSkillsProfile, advancedNativeLeaderSkillsProfile, classicHomeworldLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, pairedNexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile } from './leader-skill-profile'; +import { classicDiscoveryLeaderSkillsProfile, classicDiscoveryNexusProfile, homeworldDiscoveryProfile, discoveryModeSupported, discoveryModuleProfile, pairedDiscoveryNexusProfile } from './discovery-module-profile'; +import { advancedMoritaniLeaderSkillsProfile, advancedNativeLeaderSkillsProfile, homeworldLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, pairedNexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile } from './leader-skill-profile'; import { bribeTimingBlock, maximumBribe, type BribeOptions } from './bribe-options'; import { quoteSpicePlacement, stormExposesTerritory, stormSectorAfter, wormConsumesForces } from './disaster-rules'; import { isStormCardDistance, type StormCardComponent } from './storm-cards'; @@ -7622,7 +7622,8 @@ function moritaniAssassinateModeSupported(g: Game) { (!g.nexusCards || (!!g.leaderSkills && standaloneE3NexusLeaderSkillsProfile(g))) && (!g.leaderSkills || advancedMoritaniLeaderSkillsProfile(g)) && (!g.strongholdCards || e3StrongholdFactionProfile(g) || - (!!g.leaderSkills && strongholdLeaderSkillsProfile(g))) && !g.homeworlds && + (!!g.leaderSkills && strongholdLeaderSkillsProfile(g))) && + (!g.homeworlds || (!!g.leaderSkills && homeworldLeaderSkillsProfile(g))) && !g.discoveryEnabled && !g.discoveries && (!g.techTokens || nativeFactionTechProfile(g) || (!!g.leaderSkills && nativeTechLeaderSkillsProfile(g))); } @@ -7652,7 +7653,8 @@ function moritaniAssassinateIntegrity(g: Game) { const opponent = getPlayer(g,receipt.opponent); requireRule(receipt.turn <= g.turn && opponent.faction === receipt.faction && receipt.owner === state.owner && (TERRITORIES.some(t => t.id === receipt.territory) || - validGameLocation(g, receipt.territory, 0)), + validGameLocation(g, receipt.territory, 0) || + (receipt.territory.startsWith('homeworld:') && !!homeworldBattleLocation(g, receipt.territory))), 'The assassination history no longer matches its original battle participants.'); } const pending = state.opportunities.filter(r => r.stage === 'skills' || r.stage === 'choice'); @@ -9524,7 +9526,7 @@ export function initializeDiscoveryGameForAudit(state: Game): Game { requireRule(discoveryModuleProfile(state), 'Discovery requires a fresh supported classic, E1/E2 or standalone E3 lobby without unrelated overlays.'); requireFreshBaseRuntime(state); requireFreshFactionInventory(state); - const homeworlds = classicHomeworldDiscoveryProfile(state); + const homeworlds = homeworldDiscoveryProfile(state); if (homeworlds) requireRule(state.homeworlds?.custody === null, 'Start Homeworld Discovery with original undealt native custody.'); const g = initializeSetupGameForAudit(state, homeworlds, false, false, true, false, false, state.expansions.length > 0); @@ -9535,10 +9537,10 @@ export function initializeDiscoveryGameForAudit(state: Game): Game { /** Gated development setup; never dispatched by a player action or room route. */ export function initializeLeaderSkillsGameForAudit(state: Game): Game { requireRule(!state.leaderSkills, 'Leader Skills cannot redeal existing skill cards.'); - const homeworlds = classicHomeworldLeaderSkillsProfile(state); + const homeworlds = homeworldLeaderSkillsProfile(state); if (state.homeworlds) { requireRule(homeworlds && state.homeworlds.custody === null, - 'Homeworld Leader Skills require a fresh classic lobby with only supported original Discovery, Tech or Advanced Stronghold modules.'); + 'Homeworld Leader Skills require a fresh supported classic or native lobby with only original Discovery, Tech or Advanced Stronghold modules.'); requireFreshBaseRuntime(state); requireFreshFactionInventory(state); } @@ -9553,7 +9555,7 @@ export function initializeLeaderSkillsGameForAudit(state: Game): Game { const initialized = initializeSetupGameForAudit(g, homeworlds, classicNexus || pairedNexusLeaderSkillsProfile(g) || standaloneE3NexusLeaderSkillsProfile(g), false, classicDiscoveryLeaderSkillsProfile(g) || nativeDiscoveryLeaderSkillsProfile(g) || (g.discoveryEnabled === true && classicNexus), true, g.expansions.length === 1 && g.expansions[0] === 'choam', nativeExpansionLeaderSkillsProfile(g)); if (advancedMoritaniLeaderSkillsProfile(initialized)) initializeMoritaniAssassinateState(initialized); - if (nativeDiscoveryLeaderSkillsProfile(initialized) && byFaction(initialized, 'ecaz')) initialized.ecazOccupyPreview = true; + if ((homeworlds || nativeDiscoveryLeaderSkillsProfile(initialized)) && byFaction(initialized, 'ecaz')) initialized.ecazOccupyPreview = true; return initialized; } /** Fresh all-fourteen-card classic or supported native skills; no public income toggle. */ @@ -9587,7 +9589,7 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f homeworldShipmentIntegrity(state); const g = structuredClone(state); requireFreshSetup(g, homeworlds || nexus || ix || factions); - const homeworldSkills = leaderSkills && classicHomeworldLeaderSkillsProfile(g); + const homeworldSkills = leaderSkills && homeworldLeaderSkillsProfile(g); const skillStronghold = leaderSkills && strongholdLeaderSkillsProfile(g); const skillTech = leaderSkills && (classicTechLeaderSkillsProfile(g) || nativeTechLeaderSkillsProfile(g) || skillStronghold || homeworldSkills || classicDiscoveryLeaderSkillsProfile(g)); const nativeTech = factions && !leaderSkills && nativeFactionTechProfile(g); @@ -21902,7 +21904,7 @@ function sukPhysicalSignature(p: Player, g?: Game) { ...(g?.homeworlds?.custody ? { homeworlds: g.homeworlds.custody } : {}) }); } function sukReserveHomes(g: Game, player: Player, territory: string, options: readonly SukRescueOption[]): Extract['reserveHomes'] { - if (!g.homeworlds?.custody || !classicHomeworldLeaderSkillsProfile(g) || + if (!g.homeworlds?.custody || !homeworldLeaderSkillsProfile(g) || !options.some(option => option.normal + option.elite > (option.kept ? 1 : 0))) return; const homes = homeworldRule(() => homeworldForceGroups(homeworldContext(g), g.homeworlds!.custody!)); if (homes.some(home => home.id === territory && home.native === player.id)) return; @@ -21972,11 +21974,11 @@ function settleSukRescue(g: Game, option: SukRescueOption, automatic: boolean, d sardaukar.casualties.options.some(choice => JSON.stringify(choice) === JSON.stringify(pending.losses)), 'The Suk rescue lost its original Nexus Sardaukar casualty allocation.'); let physical: SukPhysicalRescueQuote | undefined; - if (classicHomeworldLeaderSkillsProfile(g)) { + if (homeworldLeaderSkillsProfile(g)) { try { physical = homeworldRule(() => quoteSukPhysicalRescue(homeworldLossContext(g), g.homeworlds!.custody!, { player: player.id, territory: pending.territory, skill: pending.skill, - pool: pending.pool, losses: pending.losses!, option, destinations, + pool: pending.pool, losses: pending.losses!, option, destinations, eliteOrigins: pending.eliteOrigins, })); } catch (error) { if (error instanceof SukPhysicalRescueError) throw new RuleError(error.message); @@ -22020,7 +22022,8 @@ function settleSukRescue(g: Game, option: SukRescueOption, automatic: boolean, d : 'none remained in the battle territory'; log(g, `${player.name}'s Suk Graduate saved ${option.normal} ordinary and ${option.elite} elite forces: ${kept}, ${returned} returned to reserves, and ${tanks.normal + tanks.elite} casualties went to the Tanks.${automatic ? ' The only legal rescue was applied automatically.' : ''}`, { faction: player.faction, name: 'Suk Graduate rescue' }); observeOccupation(g); - if (pending.eliteOrigins) stageIxSubstitution(g, player, pending.territory, pending.cards, quote!.eliteTanks!); + if (pending.eliteOrigins) stageIxSubstitution(g, player, pending.territory, pending.cards, + physical ? physical.eliteTanks! : quote!.eliteTanks!); finishWinner(g, player, pending.territory, pending.cards); } function winnerDiscardSignature(pending: NonNullable) { diff --git a/game/homeworld-game.ts b/game/homeworld-game.ts index 06201e29..5bd46d82 100644 --- a/game/homeworld-game.ts +++ b/game/homeworld-game.ts @@ -38,7 +38,9 @@ export function homeworldGameIntegrity(g: Game): void { const beforeForces = g.status === 'lobby' || (g.status === 'setup' && - (g.setupStage === 'prediction' || (g.setupStage === 'leaderSkills' && !!g.leaderSkills) || g.setupStage === 'traitors')); + (g.setupStage === 'prediction' || + ((g.setupStage === 'skillTreachery' || g.setupStage === 'leaderSkills') && !!g.leaderSkills) || + g.setupStage === 'traitors')); if (beforeForces) { if (state.custody !== null) throw new HomeworldCustodyError( diff --git a/game/leader-skill-profile.ts b/game/leader-skill-profile.ts index 19eff4b5..22f77b19 100644 --- a/game/leader-skill-profile.ts +++ b/game/leader-skill-profile.ts @@ -113,6 +113,8 @@ export function standaloneE3NexusLeaderSkillsProfile(game: LeaderSkillProfile): /** Discovery keeps original native roster predicates; only this module envelope * differs. Live token/entry/Great Maker frames are original consumers. */ function discoveryLeaderSkillModulesSupported(game: LeaderSkillProfile): boolean { + if (game.homeworlds) + return game.discoveryEnabled === true && homeworldLeaderSkillModulesSupported(game); const players = game.players; return typeof game.advanced === 'boolean' && game.discoveryEnabled === true && !!players && players.length >= 2 && players.length <= 6 && @@ -129,6 +131,7 @@ function discoveryLeaderSkillModulesSupported(game: LeaderSkillProfile): boolean /** E1/E2 native skills may add Tech and Advanced Strongholds; original roster/decks remain. */ function nativeLeaderSkillModulesSupported(game: LeaderSkillProfile): boolean { + if (game.homeworlds) return homeworldLeaderSkillModulesSupported(game); if (noOtherLeaderSkillModules(game) || pairedNexusLeaderSkillsProfile(game)) return true; if (game.discoveryEnabled || game.discoveries) return discoveryLeaderSkillModulesSupported(game); const players = game.players; @@ -151,6 +154,7 @@ function nativeLeaderSkillModulesSupported(game: LeaderSkillProfile): boolean { /** Standalone E3 native predicates retain their own roster, Duke and assassination limits. */ function e3LeaderSkillModulesSupported(game: LeaderSkillProfile): boolean { + if (game.homeworlds) return homeworldLeaderSkillModulesSupported(game); if (noOtherLeaderSkillModules(game) || standaloneE3NexusLeaderSkillsProfile(game)) return true; if (game.discoveryEnabled || game.discoveries) return discoveryLeaderSkillModulesSupported(game); const players = game.players; @@ -376,20 +380,31 @@ export function nativeTechLeaderSkillsProfile(game: LeaderSkillProfile): boolean return !!game.techTokens && nativeExpansionLeaderSkillsProfile(game); } -/** Original classic Homeworld/Skills, optionally Discovery/Tech/Advanced Strongholds. */ -export function classicHomeworldLeaderSkillsProfile(game: LeaderSkillProfile): boolean { +/** Original Homeworld module envelope; native roster/deck predicates stay authoritative. */ +function homeworldLeaderSkillModulesSupported(game: LeaderSkillProfile): boolean { const players = game.players; - if (!game.homeworlds || typeof game.advanced !== 'boolean' || game.expansions.length || + if (!game.homeworlds || typeof game.advanced !== 'boolean' || !players || players.length < 2 || players.length > 6 || game.nexusCards || (game.discoveryEnabled !== true && (game.discoveryEnabled || game.discoveries || game.discoveryStash || game.greatMaker)) || (game.techTokens && players.length < 3) || (game.strongholdCards && !game.advanced) || game.ecazTreachery || - game.semutaPreview || game.moritaniAssassinatePreview || game.advancedPreview || + game.semutaPreview || game.advancedPreview || + (game.moritaniAssassinatePreview && (!game.advanced || game.expansions.length !== 1 || + game.expansions[0] !== 'ecaz' || !players.some(player => player.faction === 'moritani'))) || game.kullPreview || game.nexusKullPreview || game.guildBetrayalPreview || game.richeseBetrayalPreview || game.nexusIxianReplacementPreview || game.nexusIxianBetrayalPreview || game.nexusHarkonnenBetrayalPreview || game.mentatQuestionPreview || game.spiceBankerIncomePreview) return false; + return true; +} + +/** Original classic or existing native Homeworld/Skills, optionally Discovery/Tech/Strongholds. */ +export function homeworldLeaderSkillsProfile(game: LeaderSkillProfile): boolean { + if (!game.homeworlds) return false; + if (game.expansions.length) return nativeExpansionLeaderSkillsProfile(game); + if (!homeworldLeaderSkillModulesSupported(game)) return false; + const players = game.players!; for (let i = 0; i < players.length; i++) { for (let j = 0; j < i; j++) if (players[j].faction === players[i].faction) return false; let classic = false; @@ -408,5 +423,5 @@ export function ordinaryLeaderSkillModeSupported(game: LeaderSkillProfile): bool strongholdLeaderSkillsProfile(game) || classicNexusLeaderSkillsProfile(game) || pairedNexusLeaderSkillsProfile(game) || standaloneE3NexusLeaderSkillsProfile(game) || classicDiscoveryLeaderSkillsProfile(game) || nativeDiscoveryLeaderSkillsProfile(game) || - classicHomeworldLeaderSkillsProfile(game); + homeworldLeaderSkillsProfile(game); } diff --git a/game/reference.ts b/game/reference.ts index 02ac6cfa..d90efb04 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -3820,7 +3820,7 @@ export const RULE_TOPICS: RuleTopic[] = [ ).join(' '), steps: [ 'Homeworlds are an optional module from Ecaz & Moritani, available under the official rules with Basic or Advanced play. They are separate locations outside Arrakis; they do not receive ordinary storm movement or count as ordinary strongholds.', - 'Fresh private classic Homeworld prototypes compose all fourteen Leader Skills, Discovery seven cards/eight tokens or both, Basic/Advanced two through six unique base factions. Optional original Tech requires three or more seats; Stronghold Cards require Advanced. Original setup/native placement, phase-end industry, source-local held cards and cleanup before mandatory winner Tech remain. Typed native Suk saves stay at the same home; external Emperor returns explicitly allocate normal/elite counters to actual native homes, an implementation interpretation rather than ordinary revival placement. Nested battles receive no Homeworld bonus; later Cistern income is separate from support/rescue. Nexus/native rosters, unrelated overlays, public starts and pending interactions stay separate.', + 'Fresh private Homeworld prototypes compose all fourteen Leader Skills, Discovery seven cards/eight tokens or both, Basic/Advanced two through six classic or supported native E1/E2 seats, or standalone Ecaz OR Moritani with classic opponents. Original family decks and Duke/assassination exclusions remain. Optional original Tech requires three or more seats; Stronghold Cards require Advanced. Original setup/native placement, phase-end industry, source-local held cards and cleanup before mandatory winner Tech remain. Typed native Suk saves stay at the same home; external Emperor returns explicitly allocate normal/elite counters to actual native homes, an implementation interpretation rather than ordinary revival placement. Nested battles receive no Homeworld bonus; later Cistern income is separate from support/rescue. Nexus, unsupported E3 pairs/mixed families, unrelated overlays, public starts and pending interactions stay separate.', 'Each faction uses its own card and matching world token. In Basic play the Emperor uses Kaitain alone. Advanced play adds Salusa Secundus for its Sardaukar; movement can transfer both force types between the two Emperor worlds.', 'Native reserves determine high or low population. Salusa counts Sardaukar, while the other cards count the faction’s reserves there. Reaching the high face’s minimum restores high population. Occupation also applies the low penalty.', 'Low population normally grants one additional free force revival and one additional bank spice when collecting CHOAM Charity. Salusa grants neither bonus; CHOAM’s Tupile reverses the usual pattern by granting an advantage while low and a penalty while high.', @@ -3984,12 +3984,14 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Verification', status: 'Partial', detail: - 'Classic Homeworld Skills/Discovery or both optionally retain Tech/Advanced Strongholds:145 affected cases, typed native/visitor/nested rescue, phase-end industries, source-local held effects and cleanup before winner Tech. Fourteen completed original samples9008/9008 actions/237 JSON span two source batches after deleting an incidental undefined-key copy assertion; its original capture and actual continuation remain. Human390px nested Suk and native-world winner Tech choices retain actual armies. Earlier setup, typed transfers, native payments, occupied percentages/Bidding/private-lot binding and BG accompaniment evidence remains. The earlier bounded occupation profile completes44/50 samples; four Basic occupation/two CHOAM ruling captures remain, alongside five resumed and five fresh Advanced advisor-repair games. Full rules, all combinations, final assurance and deployed acceptance remain unverified.', + 'Native Homeworld optional modules add35 semantic cases; affected180/180 and final repair union46/46, types/lint/build pass. Original six-seat games14/19 complete; twelve native families initially134/144 Advanced three-seat completions. Two preserved Ambassador deadlocks finish after the base-board bot repair; ruling captures remain, not an uninterrupted144/final-tree certificate. Human390px Ixian original card/training and typed invasion, plus native Moritani Suk rescue, retain physical custody. Dated classic14-game9008/9008/237 and occupation44/50 evidence remains in the source-bound runtime log. Full rules, combinations, final assurance and deployed acceptance remain open.', evidence: [ 'tests/homeworld-classic-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-runtime.test.ts', 'tests/homeworld-skills-discovery-runtime.test.ts', 'tests/homeworld-optional-modules-runtime.test.ts', + 'tests/homeworld-native-e1e2-modules-runtime.test.ts', + 'tests/homeworld-native-e3-modules-runtime.test.ts', 'tests/homeworld-custody.test.ts', 'tests/homeworld-cards.test.ts', 'tests/homeworld-payment-income.test.ts', @@ -4085,8 +4087,8 @@ export const RULE_TOPICS: RuleTopic[] = [ searchText: 'Hiereg Smuggler Jacurutu Sietch Cistern Ecological Testing Station Shrine Orgiz Processing Station Treachery Card Stash Spice Stash Ornithopter', steps: [ 'Fresh Discovery development setup supports classic, selected Ixian/Tleilaxu/CHOAM/Richese families, or standalone Ecaz or Moritani with classic opponents in Basic or Advanced, with seven extra Spice Cards and eight tokens. Original unused Tech Tokens may be selected with three through six seats; original Stronghold Cards require Advanced. Other modules, paired or mixed Ecaz/Moritani families and normal public starts stay gated.', - 'Fresh classic Discovery retains Homeworlds in Basic/Advanced two through six unique base factions, original seven cards/eight tokens and native reserve placement. Original Tech requires three or more seats; Stronghold Cards require Advanced. The separate original leader-skills --discoveries entry also composes all fourteen Skills. Maker, later parent entry, nested shipment/rescue, phase-end industry and cleanup before mandatory winner Tech retain actual sources; held benefits never spill into a Homeworld or unrelated nested site. Nexus/native Homeworld rosters and unrelated overlays remain separate in this bounded profile, not outside the full goal.', - 'The fresh Leader Skills entry may explicitly add Discoveries with original classic or supported native decks, all fourteen skills, original Tech at three or more seats and Advanced Stronghold Cards. Classic profiles may retain Homeworlds without Nexus. --nexus-cards additionally retains all12 with the actual settled-alliance end-Spice closing deal after Great Maker and both Advanced piles. Existing native roster and Duke/assassination/Harkonnen restrictions remain. Actual hands/offers, physical entry, nested tariffs, stash capacity, typed rescue, native aftermath and printed invoices retain their sources. Homeworlds with Nexus or native rosters, independent variants and separate Banker/Mentat previews remain outside these bounded compositions.', + 'Fresh Discovery retains Homeworlds in Basic/Advanced two through six classic or supported native E1/E2 seats, or standalone Ecaz OR Moritani with classic opponents, original seven cards/eight tokens and native reserve placement. Original Tech requires three or more seats; Stronghold Cards require Advanced. The separate original leader-skills --discoveries entry also composes all fourteen Skills. Maker, later parent entry, nested shipment/rescue, phase-end industry and cleanup before mandatory winner Tech retain actual sources; held benefits never spill into a Homeworld or unrelated nested site. Nexus with Homeworlds, unsupported E3 pairs/mixed families and unrelated overlays remain separate in this bounded profile, not outside the full goal.', + 'The fresh Leader Skills entry may explicitly add Discoveries with original classic or supported native decks, all fourteen skills, original Tech at three or more seats and Advanced Stronghold Cards. Supported profiles may retain Homeworlds without Nexus. --nexus-cards additionally retains all12 with the actual settled-alliance end-Spice closing deal after Great Maker and both Advanced piles. Existing native roster and Duke/assassination/Harkonnen restrictions remain. Actual hands/offers, physical entry, nested tariffs, stash capacity, typed rescue, native aftermath and printed invoices retain their sources. Homeworlds with Nexus, unsupported E3 pairs/mixed families, independent variants and separate Banker/Mentat previews remain outside these bounded compositions.', 'The same fresh entry also adds --nexus-cards --discoveries to paired Ixians+Tleilaxu, paired CHOAM+Richese or standalone Ecaz OR Moritani with classics: original all14 offered training, all12 Nexus and Discovery7/8, Basic/Advanced two through six, optional Tech at three or more seats and Advanced Strongholds. Original setup, first hands, offers and native components stay authoritative. Real Maker losses/votes/typed rides, both Advanced piles and settled alliance precede the end-Spice closing draw. E1 Suboid Cunning settles typed Suk rescue before one equal Cyborg Tank substitution; original cleanup and mandatory winner Tech precede a matching Face Dance. E2 keeps the signed two-marker frame and one invoice. Ecaz keeps quiet living/unclaimed Duke separate from training and a real owner-labelled mandatory Occupy; Moritani keeps skill-first assassination and one private Mentat replacement. Mixed/E3 pairs, Homeworlds, shared Duke, borrowed Smuggler/pair companions, native free returns, captured/foreign training, Basic odd Ecaz and Advanced Harkonnen stay separate.', 'A separate fresh Nexus entry may explicitly add Discoveries to classic factions or both native Ixians/Tleilaxu or CHOAM/Richese with classics, one original family deck and no Skills. Original Tech requires three or more seats; Stronghold Cards require Advanced. End-Mentat ownership and next-turn physical entry remain. Great Maker finishes its losses, votes and typed reserve ride before ordinary Nexus settlement; cards are offered only at the end of the entire Spice Blow after both Advanced piles and settled alliance, not immediately or at Mentat. Source-clear return ledgers, Guild-priced nested shipment and signed native one-invoice marker pairs retain physical pieces and original capacities. E3/mixed families, Homeworlds, unrelated previews and unresolved mixed-module Fremen Betrayal stay separate from this no-Skills entry.', 'Original Stronghold Cards are claimed only at end-Mentat. A held benefit applies in its named territory or the mobile stronghold through its actual declared copy; revealed Discovery locations do not inherit another territory’s defense or bank support. Jacurutu counts toward stronghold victory but creates no seventh Stronghold Card. Physical free entry, nested tariffs, trained or native force casualties, card cleanup and optional Tech reward retain their original sources.', @@ -4128,8 +4130,8 @@ export const RULE_TOPICS: RuleTopic[] = [ }, { area: 'Verification', status: 'Partial', - detail: 'Classic Homeworld Discovery with optional all14 Skills/Tech/Advanced Strongholds adds original Maker, parent entry, typed nested rescue, phase-end industry, source-local held cards and cleanup before native-world winner Tech. Affected145 cases and fourteen completed samples9008/9008 actions/237 JSON span two source batches after an incidental undefined-key copy assertion was deleted; original capture/continuation are preserved. Human390px nested Suk and mandatory Tech choices retain real armies. Earlier native/skill/Nexus phone programmes prove entry, tariffs/No-Field, stash, HMS support, CHOAM invoices and native transitions;31 native skill samples complete after three hidden-Smuggler repairs across two trees. Earlier proofs and the exhausted-cache ruling capture remain separate. Not every-effect/full combinations, assurance or deployed acceptance.', - evidence: ['tests/homeworld-classic-discovery-runtime.test.ts', 'tests/homeworld-skills-discovery-runtime.test.ts', 'tests/homeworld-optional-modules-runtime.test.ts', 'tests/discovery-native-e1-skills-runtime.test.ts', 'tests/discovery-native-e2-skills-runtime.test.ts', 'tests/discovery-native-e3-skills-runtime.test.ts', 'tests/discovery-skills-strongholds-runtime.test.ts', 'tests/discovery-native-strongholds-runtime.test.ts', 'tests/ecaz-stronghold-runtime.test.ts', 'tests/e3-native-tech-runtime.test.ts', 'tests/discovery-classic-nexus-lifecycle.test.ts', 'tests/discovery-classic-nexus-effects.test.ts', 'tests/discovery-orgiz-advanced-runtime.test.ts', 'tests/discovery-classic-skills-runtime.test.ts', 'tests/discovery-module-profile.test.ts', 'tests/discovery-native-typed-runtime.test.ts', 'tests/discovery-native-marker-runtime.test.ts', 'tests/discovery-native-e3-runtime.test.ts', 'tests/discoveries.test.ts', 'tests/discovery-runtime.test.ts', 'tests/great-maker.test.ts', 'tests/discovery-board.test.ts', 'tests/discovery-admission.test.ts', 'tests/discovery-controls.test.ts', 'tests/discovery-entry.test.ts', 'tests/discovery-entry-runtime.test.ts', 'tests/discovery-entry-controls.test.ts', 'tests/discovery-entry-review.test.ts', 'tests/discovery-collection.test.ts', 'tests/discovery-flight.test.ts', 'tests/discovery-flight-engine.test.ts', 'tests/discovery-flight-recovery.test.ts', 'tests/discovery-battle.test.ts', 'tests/discovery-battle-recovery.test.ts', 'tests/discovery-storm.test.ts', 'tests/discovery-storm-recovery.test.ts', 'tests/discovery-shrine-engine.test.ts', 'tests/discovery-shrine-recovery.test.ts'], + detail: 'Native Homeworld Discovery with optional all14 Skills/Tech/Advanced Strongholds adds original native setup, source-qualified parent/nested entry, capped No-Fields and physical aftermath. New35 cases; affected180/180, final repair46/46, types/lint/build pass. Twelve native families initially134/144 Advanced three-seat completions; two original Ambassador placement deadlocks finish after the bot respects existing base-board targets. CHOAM source captures and prior six-seat ruling captures remain. Human390px Ixian typed invasion and native Moritani Suk retain original armies. Dated classic/native/Nexus proofs remain separate in canonical docs; not every-effect/full combinations, assurance or deployed acceptance.', + evidence: ['tests/homeworld-native-e1e2-modules-runtime.test.ts', 'tests/homeworld-native-e3-modules-runtime.test.ts', 'tests/homeworld-classic-discovery-runtime.test.ts', 'tests/homeworld-skills-discovery-runtime.test.ts', 'tests/homeworld-optional-modules-runtime.test.ts', 'tests/discovery-native-e1-skills-runtime.test.ts', 'tests/discovery-native-e2-skills-runtime.test.ts', 'tests/discovery-native-e3-skills-runtime.test.ts', 'tests/discovery-skills-strongholds-runtime.test.ts', 'tests/discovery-native-strongholds-runtime.test.ts', 'tests/ecaz-stronghold-runtime.test.ts', 'tests/e3-native-tech-runtime.test.ts', 'tests/discovery-classic-nexus-lifecycle.test.ts', 'tests/discovery-classic-nexus-effects.test.ts', 'tests/discovery-orgiz-advanced-runtime.test.ts', 'tests/discovery-classic-skills-runtime.test.ts', 'tests/discovery-module-profile.test.ts', 'tests/discovery-native-typed-runtime.test.ts', 'tests/discovery-native-marker-runtime.test.ts', 'tests/discovery-native-e3-runtime.test.ts', 'tests/discoveries.test.ts', 'tests/discovery-runtime.test.ts', 'tests/great-maker.test.ts', 'tests/discovery-board.test.ts', 'tests/discovery-admission.test.ts', 'tests/discovery-controls.test.ts', 'tests/discovery-entry.test.ts', 'tests/discovery-entry-runtime.test.ts', 'tests/discovery-entry-controls.test.ts', 'tests/discovery-entry-review.test.ts', 'tests/discovery-collection.test.ts', 'tests/discovery-flight.test.ts', 'tests/discovery-flight-engine.test.ts', 'tests/discovery-flight-recovery.test.ts', 'tests/discovery-battle.test.ts', 'tests/discovery-battle-recovery.test.ts', 'tests/discovery-storm.test.ts', 'tests/discovery-storm-recovery.test.ts', 'tests/discovery-shrine-engine.test.ts', 'tests/discovery-shrine-recovery.test.ts', 'docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md'], }, ], }, @@ -4648,7 +4650,7 @@ export const RULE_TOPICS: RuleTopic[] = [ developmentStage: 'Prototyped', summary: 'Fourteen physical skills, private assignment choices, five battle disciplines, Planetologist movement/battle effects and Suk Graduate rescues, Mentat/Bureaucrat scoring and Bureaucrat payment redirection, Sandmaster victory spice, Rihani exchanges, optional Smuggler shipping and unmodified battle collection, Sandmaster route and worm-ride collection, sealed Spice Banker battle spending and optional Diplomat base-defense copying in development tables. Other effects and combined-module acceptance remain unfinished.', steps: [ - 'Fresh classic Leader Skills retains Homeworlds in Basic/Advanced two through six unique base factions and all fourteen original offers, optionally original Discovery7/8, Tech at three or more seats and Advanced Stronghold Cards. Native placement follows training. Normal/trained Suk uses actual native, visitor or nested physical pools; native saved counters remain at that home. External Emperor returns explicitly allocate ordinary/elite counters to actual native homes, an implementation interpretation rather than ordinary revival placement. Support precedes rescue; later Cistern income, source-local held effects and mandatory winner Tech after original cleanup stay separate. Bureaucrat invoices and minimal legal bots are connected. Nexus/native Homeworld rosters, unrelated overlays and pending interactions stay separate.', + 'Fresh Leader Skills retains Homeworlds in Basic/Advanced two through six classic or supported native E1/E2 seats, or standalone Ecaz OR Moritani with classic opponents, and all fourteen original offers, optionally original Discovery7/8, Tech at three or more seats and Advanced Stronghold Cards. Native placement follows training, including the original Ixian pre-training card choice. Normal/trained Suk uses actual native, visitor or nested physical pools; native saved counters remain at that home. External Emperor returns explicitly allocate ordinary/elite counters to actual native homes, an implementation interpretation rather than ordinary revival placement. Actual residual Cyborg Tanks feed optional equal Suboid substitution after rescue. Support precedes rescue; later Cistern income, source-local held effects and mandatory winner Tech after original cleanup stay separate. Bureaucrat invoices and minimal legal bots are connected. Nexus with Homeworlds, unsupported E3 pairs/mixed families, unrelated overlays and pending interactions stay separate.', 'Basic Moritani retains base opponents including Harkonnen. Its separate bounded Advanced classic/non-Harkonnen roster now composes the original assassination preview and all fourteen skills: winner Suk rescue and Rihani choice finish first, then an eligible different trained disc may be assassinated with its exact card return and printed bounty. No casualties repeat; original set-aside and one private Mentat replacement remain. See Moritani with Leader Skills for the exact scope.', 'Fresh Advanced profiles compose native Ixians, Tleilaxu, CHOAM and/or Richese with classic opponents and distinct required family decks. All fourteen skills and native starts remain. Auditor stays unskilled; cyborg casualties, original CHOAM support and Richese capped marker revelation remain physical. Foreign revival creates no new skill. Native Ixian Technology on Richese lots, own mixed No-Field, captured replacement, Advanced Atreides Suk/KH and unrelated overlays retain their guards.', 'Fresh three-through-six-seat Basic/Advanced development tables may compose all fourteen Skills with original Tech Tokens: classic factions, supported E1/E2 native families, or standalone Ecaz/Moritani with the exact ecaz deck and original roster exclusions. Starting cards/cache, skill/Traitor choices and printed/first-Storm assignment stay genuine. Existing leader-skills/banker-income entry preserves unused selected tokens; mixed E3 families, other overlays, public activation and saved-game conversion remain separate.', @@ -4686,7 +4688,7 @@ export const RULE_TOPICS: RuleTopic[] = [ { area:'Player controls',status:'Partial',detail:'Private card/leader selection, readable inspectors, public battle posture and revival draw/decline, Planetologist range/gather controls, Special weapon selection and Suk Graduate physical rescue choices are connected. Rihani has private history and separate keep-new/reveal-old controls; automatic battle effects need no confirmation. Smuggler has explicit ordinary shipping opt-out and No-Field companion opt-in with physical-versus-priced force counts, plus automatic surviving-leader battle collection with reveal-time guidance. Banker exposes separate sealed spending and authorized inspection. Sandmaster exposes explicit legal routes, per-territory collection choices and an optional worm-destination checkbox. Diplomat offers a named committed Worthless choice or decline after public reveal, and a separate surviving-loser destination/normal/elite retreat or decline. Bureaucrat offers redirect or full payment with explicit recipient amounts and deferred bribe custody. Remaining effect controls are missing.',evidence:['components/leader-skills.tsx','components/planetologist-movement.tsx','components/suk-graduate.tsx','components/rihani-decipherer.tsx','components/mentat-question.tsx','components/bureaucrat-payment.tsx','components/leader-skill-battle-guide.tsx','components/smuggler-shipment.tsx','components/spice-banker.tsx','components/sandmaster-movement.tsx','components/sandmaster-worm.tsx','components/diplomat-defense.tsx','components/diplomat-retreat.tsx','components/game-table.tsx'] }, { area:'AI',status:'Partial',detail:'All four profiles use private offers, legal visibility/revival actions and Planetologist movement/battle candidates and legal Suk Graduate rescues, Rihani exchanges, shared battle scoring, Smuggler reserve/owned No-Field shipments and unmodified collection plans, Sandmaster route and worm collection, funded Banker commitments and optional Diplomat copying from public revealed defenses or choosing legal losing-battle retreat. Bureaucrat uses the public payee alliance to redirect enemy income or preserve allied income. These are legal prototype paths; complete skill strategy and difficulty calibration remain unfinished.',evidence:['game/bots.ts'] }, { area:'Documentation',status:'Partial',detail:'All fourteen faces and common rules are recorded. Skilled capture follows public physical-card custody; replacement entitlement and other material timing interpretations remain explicit.',evidence:['docs/LEADER_SKILLS_RULES.md','docs/LEADER_SKILLS_CAPTURE.md','docs/LEADER_SKILLS_RUNTIME.md','docs/PLANETOLOGIST_RULES.md','docs/SUK_GRADUATE_RULES.md','docs/LEADER_BATTLE_EFFECTS.md','docs/MENTAT_QUESTION.md','docs/BUREAUCRAT_PAYMENTS.md','docs/SMUGGLER_SHIPMENT.md','docs/SPICE_BANKER_RUNTIME.md','docs/SANDMASTER_WORM.md','docs/DIPLOMAT_DEFENSE.md'] }, - { area:'Verification',status:'Partial',detail:'Classic Homeworld Skills optionally composes Discovery/Tech/Advanced Strongholds:145 affected cases, original nested/native/visitor rescue and physical industry/held-card/reward ordering. Fourteen completed samples9008/9008 actions/237 JSON span two source batches after an incidental undefined-key copy assertion was deleted; original capture/continuation remain. Human390px typed nested Suk and Homeworld winner Tech choice retain actual armies. Dated classic/native Skills-Tech and E1/E2 Stronghold evidence remains. Standalone E3 adds15 meaningful cases,152 affected, types/lint/build, nine actual programs and eight games1531 actions/38 continuations; expanded47-game batch13936/358. Human normal Warmaster1/Duke6/Carthag Shield wins7–6/releases Duke; Guild Suk precedes unused Master3 death/bounty, winner Tech and one private Caid. Full combinations, assurance, public/deployed acceptance stay open.',evidence:['tests/homeworld-classic-skills-runtime.test.ts','tests/homeworld-skills-discovery-runtime.test.ts','tests/homeworld-optional-modules-runtime.test.ts','tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/leader-battle-effects.test.ts','tests/suk-graduate.test.ts','tests/mentat-question.test.ts','tests/bureaucrat-payment.test.ts','tests/smuggler-battle.test.ts','tests/spice-banker.test.ts','tests/advanced-native-skills.test.ts','tests/fixture-advanced-native-skills.ts','tests/skills-tech-payments-runtime.test.ts','tests/skills-tech-battle-runtime.test.ts','tests/ix-choam-skills-tech-runtime.test.ts','tests/tleilaxu-skills-tech-runtime.test.ts','tests/richese-skills-tech-runtime.test.ts','tests/classic-skills-stronghold-runtime.test.ts','tests/native-skills-stronghold-runtime.test.ts','tests/ecaz-skills-modules-runtime.test.ts','tests/moritani-skills-modules-runtime.test.ts','docs/LEADER_SKILLS_RUNTIME.md','docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md'] }, + { area:'Verification',status:'Partial',detail:'Native Homeworld Skills optionally retains Discovery/Tech/Advanced Strongholds:35 new cases, affected180/180 and final repair46/46, types/lint/build pass. Original six-seat games14/19 complete; twelve native families initially134/144 Advanced three-seat completions, with two preserved Ambassador deadlocks completing after the canonical-target bot repair. Ruling captures remain; no uninterrupted144/final-tree certificate. Human390px actual Ixian pre-training card/training and typed native invasion, plus native Moritani Suk rescue, retain original custody. Dated classic/native skill, module and assassination proofs remain in canonical source logs. Full combinations, assurance, public/deployed acceptance stay open.',evidence:['tests/homeworld-native-e1e2-modules-runtime.test.ts','tests/homeworld-native-e3-modules-runtime.test.ts','tests/homeworld-classic-skills-runtime.test.ts','tests/homeworld-skills-discovery-runtime.test.ts','tests/homeworld-optional-modules-runtime.test.ts','tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/leader-battle-effects.test.ts','tests/suk-graduate.test.ts','tests/mentat-question.test.ts','tests/bureaucrat-payment.test.ts','tests/smuggler-battle.test.ts','tests/spice-banker.test.ts','tests/advanced-native-skills.test.ts','tests/fixture-advanced-native-skills.ts','tests/skills-tech-payments-runtime.test.ts','tests/skills-tech-battle-runtime.test.ts','tests/ix-choam-skills-tech-runtime.test.ts','tests/tleilaxu-skills-tech-runtime.test.ts','tests/richese-skills-tech-runtime.test.ts','tests/classic-skills-stronghold-runtime.test.ts','tests/native-skills-stronghold-runtime.test.ts','tests/ecaz-skills-modules-runtime.test.ts','tests/moritani-skills-modules-runtime.test.ts','docs/LEADER_SKILLS_RUNTIME.md','docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md'] }, ], }, { diff --git a/game/suk-graduate.ts b/game/suk-graduate.ts index 534d3573..fdbe830e 100644 --- a/game/suk-graduate.ts +++ b/game/suk-graduate.ts @@ -43,6 +43,8 @@ export type SukPhysicalRescueRequest = { losses: Casualties; option: SukRescueOption; destinations?: SukReserveDestinations; + /** Preserve actual per-source Cyborg Tank losses for subsequent substitution. */ + eliteOrigins?: true; }; export type SukPhysicalRescueReceipt = { player: string; @@ -58,6 +60,7 @@ export type SukPhysicalRescueQuote = { players: HomeworldCombatLossPlayer[]; removed: SukForceGroup[]; receipt: SukPhysicalRescueReceipt; + eliteTanks?: Record; }; export function sukReceiptSignature(receipt: SukRescueReceipt): string { @@ -171,7 +174,7 @@ export class SukPhysicalRescueError extends Error { } } -/** Detached classic Homeworld settlement, including Arrakis rescue returns. +/** Detached Homeworld settlement, including Arrakis rescue returns. * Printed Suk saves only real casualties. Native Homeworld forces already ARE * reserves, so every saved native counter remains in its original pool. * @@ -190,7 +193,7 @@ export function quoteSukPhysicalRescue( quoteHomeworldCombatLoss(context, custody, { location: nativeHomes[0].id, player: request.player, losses: { normal: 0, elite: 0 }, }); - const rescue = quoteSukRescue(request.skill, request.pool, request.losses, request.option); + const rescue = quoteSukRescue(request.skill, request.pool, request.losses, request.option, request.eliteOrigins); const home = homes.find(candidate => candidate.id === request.territory); if (request.territory.startsWith('homeworld:') && !home) throw new SukPhysicalRescueError('Suk Graduate needs its original canonical battle Homeworld.'); @@ -254,7 +257,7 @@ export function quoteSukPhysicalRescue( location: nativeHomes[0].id, player: request.player, losses: { normal: 0, elite: 0 }, }); return { - custody: transaction.state, players, removed, + custody: transaction.state, players, removed, eliteTanks: rescue.eliteTanks, receipt: { player: request.player, territory: request.territory, source: home ? native ? 'native-homeworld' : 'visitor-homeworld' : 'arrakis', diff --git a/tests/discovery-module-profile.test.ts b/tests/discovery-module-profile.test.ts index 9aa5b645..f9aebcd1 100644 --- a/tests/discovery-module-profile.test.ts +++ b/tests/discovery-module-profile.test.ts @@ -45,10 +45,9 @@ void test('Discovery cannot redeal started components or silently remove another const initial = lobby(['ixians', 'tleilaxu', 'guild'], ['ix']); const started = initializeDiscoveryGameForAudit(initial); assert.throws(() => initializeDiscoveryGameForAudit(started), RuleError); - const overlays: Game[] = [structuredClone(initial), structuredClone(initial), structuredClone(initial)]; - overlays[0].homeworlds = { custody: null }; - overlays[1].nexusCards = { cards: null, phase: null }; - overlays[2].advanced = false; overlays[2].strongholdCards = createStrongholdCards(); + const overlays: Game[] = [structuredClone(initial), structuredClone(initial)]; + overlays[0].nexusCards = { cards: null, phase: null }; + overlays[1].advanced = false; overlays[1].strongholdCards = createStrongholdCards(); for (const game of overlays) assert.throws(() => initializeDiscoveryGameForAudit(game), RuleError); }); diff --git a/tests/fixture-homeworld-classic-discovery.ts b/tests/fixture-homeworld-classic-discovery.ts index 3809d201..e223615a 100644 --- a/tests/fixture-homeworld-classic-discovery.ts +++ b/tests/fixture-homeworld-classic-discovery.ts @@ -3,7 +3,7 @@ import { applyAction, createGame, initializeDiscoveryGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game, } from '../game/engine'; -import { classicHomeworldDiscoveryProfile } from '../game/discovery-module-profile'; +import { homeworldDiscoveryProfile } from '../game/discovery-module-profile'; import { DISCOVERY_CARD_PLACEMENTS, DISCOVERY_SPICE_CARDS, DISCOVERY_TOKEN_BY_ID, validateDiscoveryState, type DiscoveryLocationId, type DiscoveryOpaqueTokenId } from '../game/discoveries'; import { discoveryEntryMoveAction } from '../game/discovery-entry-options'; @@ -42,6 +42,8 @@ export const homeworldClassicDiscoveryPlayer = (game: Game, actor: string) => { export function nextHomeworldClassicDiscoveryStep(game: Game): HomeworldClassicDiscoveryStep { if (!game.phaseOpening && !game.response) { const decision = game.decision; + if (decision?.kind === 'homeworldShipmentGuild') + return { actor: decision.player, action: { type: 'decision', event: decision.event, allow: true } }; if (decision?.kind === 'discoveryEntry' || decision?.kind === 'greatMakerRide') return { actor: decision.player, action: { type: 'decision', event: decision.event, accept: false } }; if (decision?.kind === 'greatMakerVote') @@ -118,7 +120,7 @@ export function createHomeworldClassicDiscoveryFixture(options: HomeworldClassic game.discoveryEnabled = true; } assert.ok(game.status === 'lobby' || (game.status === 'setup' && game.turn === 1 && game.phase === 0)); - assert.ok(classicHomeworldDiscoveryProfile(game)); + assert.ok(homeworldDiscoveryProfile(game)); const collector = game.players.find(player => player.faction === 'emperor')!.id; const fremen = game.players.find(player => player.faction === 'fremen')!.id; assert.ok(collector && fremen); diff --git a/tests/fixture-homeworld-native-e1e2-modules.ts b/tests/fixture-homeworld-native-e1e2-modules.ts new file mode 100644 index 00000000..5e124169 --- /dev/null +++ b/tests/fixture-homeworld-native-e1e2-modules.ts @@ -0,0 +1,294 @@ +import assert from 'node:assert/strict'; +import { + applyAction, createGame, initializeDiscoveryGameForAudit, initializeLeaderSkillsGameForAudit, + joinGame, newPlayer, viewGame, type Action, type Game, type Player, +} from '../game/engine'; +import type { FactionId } from '../game/catalog'; +import { treacheryDeck, type Card, type SpiceCard } from '../game/cards'; +import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards'; +import { validateLeaderSkills } from '../game/leader-skills'; +import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game'; +import { homeworldForceGroups } from '../game/homeworld-custody'; +import { nativeShipmentSources } from '../game/homeworld-options'; +import { homeworldShipmentChoice } from '../game/homeworld-shipment-options'; +import { DISCOVERY_CARD_PLACEMENTS, DISCOVERY_TOKEN_BY_ID, validateDiscoveryState, + type DiscoveryLocationId, type DiscoveryOpaqueTokenId } from '../game/discoveries'; +import { discoveryEntryMoveAction } from '../game/discovery-entry-options'; +import { nextDiscoveryNativeE2SkillsStep } from './fixture-discovery-native-e2-skills'; +import { nextHomeworldSkillsDiscoveryStep } from './fixture-homeworld-skills-discovery'; + +export type NativeHomeworldModuleStep = { actor: string; action: Action }; +export type NativeHomeworldModuleOptions = { + advanced?: boolean; + expansions?: readonly string[]; + roster?: readonly FactionId[]; + skills?: boolean; + discovery?: boolean; + tech?: boolean; + strongholds?: boolean; + skill?: LeaderSkillId; + /** Select original undealt deck faces, never a held hand or physical owner. */ + startingKinds?: readonly Card['kind'][]; +}; +export type NativeHomeworldModuleSetup = { + initial: Game; + offered: Game | null; + afterSetup: Game; + actions: NativeHomeworldModuleStep[]; +}; +export type NativeHomeworldSukProgramme = NativeHomeworldModuleSetup & { + game: Game; + beforeShipment: Game; + afterShipment: Game; + owner: string; + opponent: string; + trainer: string; + territory: string; + band: 'normal' | 'skilled'; + plans: NativeHomeworldModuleStep[]; +}; +export type NativeHomeworldDiscoveryProgramme = NativeHomeworldModuleSetup & { + game: Game; + owner: string; + token: DiscoveryOpaqueTokenId; + face: DiscoveryLocationId; + parent: string; + parentSector: number; + beforeShipment: Game; + afterShipment: Game; +}; +export const nativeHomeworldClean = (game: Game) => !game.phaseOpening && !game.response && !game.decision; +export function nativeHomeworldPlayer(game: Game, actor: string): Player { + const player = game.players.find(p => p.id === actor); assert.ok(player); return player; +} +export function nativeHomeworldArmy(game: Game, actor: string, location: string) { + const home = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!).find(h => h.id === location); + assert.ok(home); return home.forces[actor] ?? { normal: 0, elite: 0 }; +} +export function nativeHomeworldInventory(game: Game): void { + homeworldGameIntegrity(game); + if (game.leaderSkills) validateLeaderSkills(game.leaderSkills, game.players); + if (game.discoveries) validateDiscoveryState(game.discoveries); + const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand), + ...(game.ixSetupCards ?? []), ...(game.auction?.cards.slice(game.auction.index) ?? [])]; + const ordinary = cards.filter(c => !c.id.startsWith('richese-')); + assert.deepEqual(ordinary.map(c => c.id).sort(), treacheryDeck(game.expansions).map(c => c.id).sort()); + const homes = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!); + for (const p of game.players) { + const visitors = homes.filter(h => h.native !== p.id).map(h => h.forces[p.id] ?? { normal: 0, elite: 0 }); + const board = Object.values(p.forces).reduce((n, count) => n + count, 0); + const visitorCount = visitors.reduce((n, group) => n + group.normal + group.elite, 0); + assert.equal(p.reserves + p.tanks + board + visitorCount, 20, p.faction); + if (p.elites) assert.equal(p.elites.reserves + p.elites.tanks + + Object.values(p.elites.forces).reduce((n, count) => n + count, 0) + visitors.reduce((n, g) => n + g.elite, 0), + p.faction === 'ixians' ? 7 : p.faction === 'emperor' ? 5 : 3, p.faction); + } +} +function permutationDraws(source: readonly T[], desired: readonly T[]): number[] { + const current = [...source], rolls: number[] = []; + for (let i = current.length - 1; i > 0; i--) { + const j = current.indexOf(desired[i]); assert.ok(j >= 0 && j <= i); + rolls.push(Math.floor((j + 0.5) / (i + 1) * 0x100000000)); + [current[i], current[j]] = [current[j], current[i]]; + } + assert.deepEqual(current, desired); return rolls; +} +/** Entropy selection is confined to original setup or an unused supply lottery. + * Every offer, hand, traitor, Face Dancer and native inventory is engine-created. */ +function originalLottery(rolls: readonly number[], operation: () => T): T { + const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); + const native = crypto.getRandomValues.bind(crypto); let draw = 0; + crypto.getRandomValues = (array: V): V => { + if (array instanceof Uint32Array && array.length === 1) array[0] = rolls[draw++] ?? 0xffffffff; + else Reflect.apply(native, crypto, [array]); + return array; + }; + try { return operation(); } + finally { + if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); + else Reflect.deleteProperty(crypto, 'getRandomValues'); + } +} +export function nextNativeHomeworldModuleStep(game: Game, hideOwner = 'ixians'): NativeHomeworldModuleStep { + if (!game.response && !game.phaseOpening && game.decision) { + const d = game.decision; + if (d.kind === 'discoveryEntry' || d.kind === 'greatMakerRide') + return { actor: d.player, action: { type: 'decision', event: d.event, accept: false } }; + if (d.kind === 'greatMakerVote') + return { actor: d.player, action: { type: 'decision', event: d.event, yes: false } }; + if (d.kind === 'leaderSkillVisibility') return { actor: d.player, action: { + type: 'leaderSkillVisibility', event: d.event, hide: d.player === hideOwner, + } }; + if (d.kind === 'faceDance') return { actor: d.player, action: { type: 'decision', reveal: false } }; + if (d.kind === 'ixSubstitution') return { actor: d.player, action: { type: 'decision', decline: true } }; + if (d.kind === 'sukRescue') throw Error('Choose the original physical Suk rescue explicitly.'); + if (d.kind === 'battleLosses' && game.pendingSukRescue) { + const maximum = Math.max(...d.options.map(o => o.elite)); + return { actor: d.player, action: { type: 'decision', choice: d.options.findIndex(o => o.elite === maximum) } }; + } + if (d.kind === 'techToken') return { actor: d.player, action: { type: 'decision', token: d.choices[0] } }; + } + if (game.players.some(p => p.faction === 'richese')) return nextDiscoveryNativeE2SkillsStep(game, true); + const next = nextHomeworldSkillsDiscoveryStep(game); assert.ok(next, 'Original sealed plans are explicit.'); return next; +} +export function actNativeHomeworld(game: Game, actor: string, action: Action, actions?: NativeHomeworldModuleStep[]): Game { + actions?.push({ actor, action: structuredClone(action) }); return applyAction(game, actor, action); +} +export function advanceNativeHomeworld(state: Game, until: (game: Game) => boolean, + actions?: NativeHomeworldModuleStep[], hideOwner = 'ixians'): Game { + let game = state; + for (let count = 0; count < 2000; count++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished'); + const step = nextNativeHomeworldModuleStep(game, hideOwner); game = actNativeHomeworld(game, step.actor, step.action, actions); + } + throw Error('Original native Homeworld programme did not reach its requested window.'); +} +export function createNativeHomeworldModuleSetup(options: NativeHomeworldModuleOptions = {}): NativeHomeworldModuleSetup { + const roster = options.roster ?? ['ixians', 'tleilaxu', 'guild', 'fremen']; + const skills = options.skills ?? true, discovery = options.discovery ?? true; + assert.ok(skills || discovery); + let game = createGame('NATIVEHWMODULES', newPlayer(roster[0], roster[0], roster[0]), options.advanced ?? false, + [...(options.expansions ?? ['ix'])]); + for (const faction of roster.slice(1)) joinGame(game, newPlayer(faction, faction, faction)); + game = applyAction(game, game.host, { type: 'homeworlds', enabled: true }); + if (options.tech) game = applyAction(game, game.host, { type: 'techTokens', enabled: true }); + if (options.strongholds) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: true }); + if (discovery) game.discoveryEnabled = true; + for (const p of game.players) game = applyAction(game, p.id, { type: 'ready' }); + const initial = structuredClone(game), actions: NativeHomeworldModuleStep[] = []; + const selected: LeaderSkillId[] = [options.skill ?? 'suk-graduate']; + const passive: LeaderSkillId[] = ['prana-bindu-adept', 'swordmaster-of-ginaz', 'killer-medic', 'warmaster', 'sandmaster']; + for (let i = 1; i < roster.length; i++) selected.push(passive.find(id => !selected.includes(id))!); + const canonical = LEADER_SKILL_CARDS.map(c => c.id), rest = canonical.filter(id => !selected.includes(id)); + const desired = selected.flatMap(id => [id, rest.shift()!]).concat(rest); + const cards = treacheryDeck(game.expansions), remaining = [...cards]; + const prefix = (options.startingKinds ?? roster.map((_, i) => i < 5 ? 'worthless' as const : 'shield' as const)).map(kind => { + const index = remaining.findIndex(c => c.kind === kind); assert.ok(index >= 0); return remaining.splice(index, 1)[0]; + }); + const rolls = [...(skills ? permutationDraws(canonical, desired) : []), + ...(discovery ? Array.from({ length: 7 }, () => 0xffffffff) : []), + ...permutationDraws(cards.map(c => c.id), [...prefix, ...remaining].map(c => c.id))]; + game = originalLottery(rolls, () => skills ? initializeLeaderSkillsGameForAudit(game) : initializeDiscoveryGameForAudit(game)); + let offered: Game | null = null; + for (let count = 0; game.status === 'setup' && count < 250; count++) { + let step: NativeHomeworldModuleStep; + if (game.setupStage === 'leaderSkills' && !game.decision) { + offered ??= structuredClone(game); + const actor = Object.keys(game.leaderSkills!.offers)[0], view = viewGame(game, actor).leaderSkills!; + const skill = selected[game.players.findIndex(p => p.id === actor)], offer = view.offer!; + assert.ok(offer.cards.includes(skill) && !view.unavailableSkills?.[skill]); + const eligible = new Set(view.eligibleLeaders.map(l => l.id)); + const leader = nativeHomeworldPlayer(game, actor).leaders.filter(l => eligible.has(l.id)).sort((a, b) => b.strength - a.strength)[0]; + assert.ok(leader); step = { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: leader.id } }; + } else if (game.decision?.kind === 'ixSetup') { + const actor = game.decision.player, card = viewGame(game, actor).ixTechnology!.setup!.find(c => c.kind === 'worthless') ?? viewGame(game, actor).ixTechnology!.setup![0]; + step = { actor, action: { type: 'decision', card: card.id } }; + } else if (game.setupStage === 'traitors' && game.players.some(p => p.traitorChoices.length)) { + const p = game.players.find(p => p.traitorChoices.length)!; + const leader = p.traitorChoices.find(id => !['ixians-0', 'ixians-1', 'ixians-2'].includes(id)) ?? p.traitorChoices[0]; + step = { actor: p.id, action: { type: 'traitor', leader } }; + } else step = nextNativeHomeworldModuleStep(game); + game = originalLottery([], () => actNativeHomeworld(game, step.actor, step.action, actions)); + } + assert.equal(game.status, 'playing'); nativeHomeworldInventory(game); + return { initial, offered, afterSetup: structuredClone(game), actions }; +} +export function nativeHomeworldMovement(state: Game, actor: string, turn = 1): Game { + return advanceNativeHomeworld(state, game => game.turn === turn && game.phase === 5 && game.active === actor && nativeHomeworldClean(game)); +} +export function settleNativeHomeworldArrival(state: Game): Game { + return advanceNativeHomeworld(state, game => nativeHomeworldClean(game) && !game.pendingShipment && !game.pendingHomeworldShipment); +} +export function invadeNativeHomeworld(game: Game, actor: string, destination: string, normal: number, elite = 0): Game { + const sources = nativeShipmentSources(viewGame(game, actor), normal + elite, elite); assert.ok(sources); + const choice = homeworldShipmentChoice(viewGame(game, actor), destination, + Object.fromEntries(Object.entries(sources).filter(([, g]) => g.normal + g.elite > 0))); + assert.ok(choice.action, choice.blocked ?? undefined); + return settleNativeHomeworldArrival(applyAction(game, actor, choice.action)); +} +export function openNativeHomeworldBattle(state: Game, first: string, second: string, territory: string, hideOwner = 'ixians'): Game { + let game = advanceNativeHomeworld(state, g => g.phase === 6 && nativeHomeworldClean(g) && !!g.active, undefined, hideOwner); + const actor = game.active!; assert.ok(actor === first || actor === second); + game = applyAction(game, actor, { type: 'chooseBattle', territory, target: actor === first ? second : first }); + return advanceNativeHomeworld(game, g => !!g.battle && nativeHomeworldClean(g) && !g.battle.preparation && g.battle.preLeader?.closed !== false, undefined, hideOwner); +} +export function nativeHomeworldLeader(game: Game, actor: string, strongest = false): string { + const trained = new Set(game.leaderSkills?.assignments.map(a => a.leader)); + const leader = nativeHomeworldPlayer(game, actor).leaders.filter(l => !l.dead && !trained.has(l.id) && !l.usedAt) + .sort((a, b) => strongest ? b.strength - a.strength : a.strength - b.strength)[0]; assert.ok(leader); return leader.id; +} +export function createNativeHomeworldSukProgramme(options: NativeHomeworldModuleOptions & { + visitor?: boolean; band?: 'normal' | 'skilled'; +} = {}): NativeHomeworldSukProgramme { + const setup = createNativeHomeworldModuleSetup({ ...options, skill: 'suk-graduate' }); + const owner = 'ixians', opponent = 'guild', band = options.band ?? 'skilled'; + const territory = options.visitor ? 'homeworld:guild' : 'homeworld:ixians'; + const actor = options.visitor ? owner : opponent; + let game = nativeHomeworldMovement(setup.afterSetup, actor); + const beforeShipment = structuredClone(game); + game = invadeNativeHomeworld(game, actor, territory, options.visitor ? 4 : 3, options.visitor ? 3 : 0); + const afterShipment = structuredClone(game); + game = openNativeHomeworldBattle(game, owner, opponent, territory, band === 'skilled' ? owner : ''); + const trainer = game.leaderSkills!.assignments.find(a => a.owner === owner)!.leader; + // The real visibility choice must be completed before either sealed plan. + if (game.battle!.leaderSkillHidden?.[owner] !== (band === 'skilled')) { + throw Error('Native continuation must select the requested Suk visibility before plans.'); + } + return { ...setup, game, beforeShipment, afterShipment, owner, opponent, trainer, territory, band, + plans: [{ actor: owner, action: { type: 'battlePlan', leader: band === 'skilled' ? trainer : nativeHomeworldLeader(game, owner, true), + dial: game.advanced ? 3 : 6, support: 0, weapon: nativeHomeworldPlayer(game, owner).hand.find(c => c.kind === 'worthless')!.id } }, + { actor: opponent, action: { type: 'battlePlan', leader: nativeHomeworldLeader(game, opponent), dial: 0, support: 0 } }] }; +} +export function revealNativeHomeworldPlans(state: Game, plans: NativeHomeworldModuleStep[]): Game { + let game = state; + for (const plan of plans) { + game = applyAction(game, plan.actor, plan.action); + if (!game.battle?.revealed) game = advanceNativeHomeworld(game, g => !!g.battle && nativeHomeworldClean(g) && !g.battle.preparation); + } + assert.ok(game.battle?.revealed); return game; +} +export function frontNativeHomeworldSpice(game: Game, choose: (card: SpiceCard) => boolean, position: number): void { + const index = game.spiceDeck.findIndex((c, i) => i >= position && choose(c)); assert.ok(index >= position); + game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]); +} +export function createNativeHomeworldDiscoveryProgramme(options: NativeHomeworldModuleOptions & { + face?: DiscoveryLocationId; collector?: string; maker?: boolean; +} = {}): NativeHomeworldDiscoveryProgramme { + const setup = createNativeHomeworldModuleSetup({ ...options, discovery: true }); + let game = structuredClone(setup.afterSetup); + const owner = options.collector ?? game.players[0].id, face = options.face ?? 'cistern'; + const card = DISCOVERY_TOKEN_BY_ID[face].type === 'hiereg' ? 'discovery-hagga-basin' : 'discovery-wind-pass-north'; + const placement = DISCOVERY_CARD_PLACEMENTS[card]; + frontNativeHomeworldSpice(game, c => 'territory' in c && c.discovery === card, 0); + const index = game.advanced ? 2 : 1; + if (game.advanced) frontNativeHomeworldSpice(game, c => 'territory' in c && !c.discovery, 1); + if (options.maker) frontNativeHomeworldSpice(game, c => 'worm' in c && !!c.greatMaker, index); + for (let i = index + (options.maker ? 1 : 0); i < index + 3; i++) + frontNativeHomeworldSpice(game, c => 'territory' in c && !c.discovery, i); + game = advanceNativeHomeworld(game, g => g.phase === 1 && nativeHomeworldClean(g), setup.actions); + const pool = game.discoveries!.tokens.filter(t => t.status === 'supply' && t.type === DISCOVERY_TOKEN_BY_ID[face].type); + const selected = pool.findIndex(t => t.face === face); assert.ok(selected >= 0); + game = originalLottery([Math.floor((selected + 0.5) / pool.length * 0x100000000)], () => + advanceNativeHomeworld(game, g => g.discoveries!.tokens.some(t => t.face === face && t.status === 'placed'), setup.actions)); + game = nativeHomeworldMovement(game, owner); + const beforeShipment = structuredClone(game), sources = nativeShipmentSources(viewGame(game, owner), 2, owner === 'ixians' ? 1 : 0); assert.ok(sources); + game = settleNativeHomeworldArrival(applyAction(game, owner, { type: 'ship', territory: placement.territory, + sector: placement.sector, amount: 2, elite: owner === 'ixians' ? 1 : 0, homeworldSources: sources, allyPayment: 0 })); + const afterShipment = structuredClone(game); + game = advanceNativeHomeworld(game, g => g.phase === 7 && nativeHomeworldClean(g)); + const token = game.discoveries!.tokens.find(t => t.face === face && t.status === 'placed')!; assert.ok(token); + return { ...setup, game, owner, token: token.id, face, parent: placement.territory, parentSector: placement.sector, beforeShipment, afterShipment }; +} +export function revealNativeHomeworldDiscovery(f: NativeHomeworldDiscoveryProgramme): Game { + let game = structuredClone(f.game); + if (viewGame(game, f.owner).discoveries!.canInspect.includes(f.token)) + game = applyAction(game, f.owner, { type: 'discovery', token: f.token, reveal: false }); + return applyAction(game, f.owner, { type: 'discovery', token: f.token, reveal: true }); +} +export function enterNativeHomeworldDiscovery(f: NativeHomeworldDiscoveryProgramme): Game { + const game = advanceNativeHomeworld(revealNativeHomeworldDiscovery(f), g => + g.turn === 2 && g.decision?.kind === 'discoveryEntry' && g.decision.player === f.owner); + const view = viewGame(game, f.owner), action = discoveryEntryMoveAction(view, view.discoveryEntry!.sources); assert.ok(action); + return applyAction(game, f.owner, action); +} diff --git a/tests/fixture-homeworld-native-e3-modules.ts b/tests/fixture-homeworld-native-e3-modules.ts new file mode 100644 index 00000000..26fd784c --- /dev/null +++ b/tests/fixture-homeworld-native-e3-modules.ts @@ -0,0 +1,397 @@ +import assert from 'node:assert/strict'; +import { + applyAction, createGame, initializeDiscoveryGameForAudit, initializeLeaderSkillsGameForAudit, + joinGame, newPlayer, viewGame, type Action, type Game, type Player, +} from '../game/engine'; +import type { FactionId } from '../game/catalog'; +import { leaders, treacheryDeck } from '../game/cards'; +import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards'; +import { validateLeaderSkills } from '../game/leader-skills'; +import { DISCOVERY_CARD_PLACEMENTS, DISCOVERY_TOKEN_BY_ID, validateDiscoveryState } from '../game/discoveries'; +import { discoveryEntryMoveAction } from '../game/discovery-entry-options'; +import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game'; +import { homeworldForceGroups, type HomeworldForces } from '../game/homeworld-custody'; +import { nativeShipmentSources } from '../game/homeworld-options'; +import { homeworldShipmentChoice } from '../game/homeworld-shipment-options'; +import { validateAmbassadors } from '../game/ecaz-ambassadors'; +import { DUKE_VIDAL_ID } from '../game/duke-vidal'; +import { traitorDeck } from '../game/traitors'; +import { nextEcazSkillsModulesStep } from './fixture-ecaz-skills-modules'; +import { nextMoritaniSkillsModulesStep, moritaniSkillsModulesPolicy } from './fixture-moritani-skills-modules'; + +export type NativeE3HomeworldStep = { actor: string; action: Action }; +export type NativeE3HomeworldOptions = { + /** Original undealt lobby: preserve seat IDs and order, without implying HTTP authentication. */ + initial?: Game; + faction?: 'ecaz' | 'moritani'; + advanced?: boolean; + roster?: readonly FactionId[]; + skills?: boolean; + discovery?: boolean; + tech?: boolean; + strongholds?: boolean; + skill?: LeaderSkillId; + /** Choose the original Guild offer/Traitor lottery for the after-loss proof. */ + assassination?: boolean; + lethal?: boolean; +}; +export type NativeE3HomeworldSetup = { + initial: Game; offered: Game; afterSetup: Game; + owner: string; opponent: string; trainer: string | null; + actions: NativeE3HomeworldStep[]; +}; +export type NativeE3HomeworldBattle = NativeE3HomeworldSetup & { + visitor: boolean; location: string; + beforeShipment: Game; shipment: NativeE3HomeworldStep; afterShipment: Game; + beforeBattle: Game; game: Game; plans: NativeE3HomeworldStep[]; +}; +export type NativeE3HomeworldDeath = { + before: Game; after: Game; leader: string; weapon: string; +}; +export type NativeE3HomeworldRevival = NativeE3HomeworldSetup & { + deaths: NativeE3HomeworldDeath[]; revivalWindow: Game; revival: NativeE3HomeworldStep; offeredRevival: Game; +}; +export type NativeE3HomeworldDiscovery = NativeE3HomeworldSetup & { + beforeShipment: Game; shipment: NativeE3HomeworldStep; afterShipment: Game; + beforeReveal: Game; afterReveal: Game; token: string; + entryWindow: Game; entry: NativeE3HomeworldStep; entered: Game; +}; +export const nativeE3HomeworldClean = (game: Game) => + !game.response && !game.phaseOpening && !game.decision && !game.pendingTreacheryDiscard; +export function nativeE3HomeworldPlayer(game: Game, actor: string): Player { + const player = game.players.find(p => p.id === actor); assert.ok(player); return player; +} +export const nativeE3HomeworldPolicy = moritaniSkillsModulesPolicy; +export function nativeE3HomeworldPool(game: Game, actor: string, location: string): HomeworldForces { + const home = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!).find(h => h.id === location); + assert.ok(home); return home.forces[actor] ?? { normal: 0, elite: 0 }; +} +export function assertNativeE3HomeworldCustody(game: Game): void { + homeworldGameIntegrity(game); + if (game.leaderSkills) validateLeaderSkills(game.leaderSkills, game.players); + if (game.discoveries) validateDiscoveryState(game.discoveries); + const homes = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!); + for (const p of game.players) { + const visitors = homes.filter(h => h.native !== p.id).map(h => h.forces[p.id] ?? { normal: 0, elite: 0 }); + assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0) + + visitors.reduce((a, b) => a + b.normal + b.elite, 0), 20, p.faction); + if (p.elites) assert.equal(p.elites.reserves + p.elites.tanks + + Object.values(p.elites.forces).reduce((a, b) => a + b, 0) + visitors.reduce((a, b) => a + b.elite, 0), + p.faction === 'fremen' ? 3 : 5, p.faction); + assert.deepEqual(p.leaders.map(l => l.id).sort(), leaders(p.faction).map(l => l.id).sort()); + } + assert.equal(game.players.find(p => ['ecaz', 'moritani'].includes(p.faction))!.leaders.length, 5); + assert.equal(game.dukeVidal!.leader.id, DUKE_VIDAL_ID); + assert.ok(!game.leaderSkills?.assignments.some(a => a.leader === DUKE_VIDAL_ID)); + if (game.ecazAmbassadors) validateAmbassadors(game.ecazAmbassadors); + const retired = game.moritaniAssassinate?.opportunities.filter(r => r.stage === 'replaced').map(r => r.card!) ?? []; + const traitors = [...game.traitorReserve!, ...game.players.flatMap(p => [...p.traitors, ...p.traitorChoices]), + ...retired, ...(game.ecazLoyalty?.card ? [game.ecazLoyalty.card] : [])]; + assert.deepEqual(traitors.sort(), traitorDeck(game.players).sort()); + const physical = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand), + ...(game.auction?.cards.slice(game.auction.index) ?? [])]; + assert.deepEqual(physical.map(c => c.id).sort(), treacheryDeck(['ecaz']).map(c => c.id).sort()); +} + +/** Fisher-Yates draw selection affects only components that have not been dealt. */ +function permutation(original: readonly T[], desired: readonly T[]): number[] { + const current = [...original], draws: number[] = []; + for (let i = current.length - 1; i > 0; i--) { + const j = current.indexOf(desired[i]); assert.ok(j >= 0 && j <= i); + draws.push(Math.floor((j + 0.5) / (i + 1) * 0x100000000)); + [current[i], current[j]] = [current[j], current[i]]; + } + assert.deepEqual(current, desired); return draws; +} +function lottery(draws: readonly number[], operation: () => T): T { + const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); + const original = crypto.getRandomValues.bind(crypto); let index = 0; + crypto.getRandomValues = (array: V): V => { + if (array instanceof Uint32Array && array.length === 1 && index < draws.length) array[0] = draws[index++]; + else Reflect.apply(original, crypto, [array]); + return array; + }; + try { return operation(); } + finally { + if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); + else Reflect.deleteProperty(crypto, 'getRandomValues'); + } +} +export function actNativeE3Homeworld(game: Game, step: NativeE3HomeworldStep, + actions?: NativeE3HomeworldStep[]): Game { + actions?.push(structuredClone(step)); return applyAction(game, step.actor, step.action); +} +/** Explicit rescue and sealed plans remain boundaries; optional assassination defaults to decline. */ +export function nextNativeE3HomeworldStep(game: Game): NativeE3HomeworldStep | null { + if (!game.response && !game.phaseOpening) { + const d = game.decision; + if (d?.kind === 'sukRescue') return null; + if (d?.kind === 'moritaniAssassinate') + return { actor: d.player, action: { type: 'decision', event: d.event, decline: true } }; + if (d?.kind === 'discoveryEntry' || d?.kind === 'greatMakerRide') + return { actor: d.player, action: { type: 'decision', event: d.event, accept: false } }; + if (d?.kind === 'greatMakerVote') return { actor: d.player, action: { type: 'decision', event: d.event, yes: false } }; + if (d?.kind === 'wormProtection') return { actor: d.player, action: { type: 'decision', accept: true } }; + if (d?.kind === 'wormRide') return { actor: d.player, action: { type: 'decision', accept: false } }; + if (d?.kind === 'homeworldDefense') return { actor: d.player, action: { type: 'decision', event: d.event, use: false } }; + if (d?.kind === 'homeworldRevivalDeployment') return { actor: d.player, action: { type: 'decision', event: d.event, decline: true } }; + } + const next = game.players.some(p => p.faction === 'ecaz') + ? nextEcazSkillsModulesStep(game) : nextMoritaniSkillsModulesStep(game); + if (next?.action.type === 'stormDial') next.action.amount = game.turn === 1 ? 0 : 1; + return next; +} +export function advanceNativeE3Homeworld(state: Game, until: (game: Game) => boolean, + actions?: NativeE3HomeworldStep[]): Game { + let game = state; + for (let n = 0; n < 2600; n++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished'); + const next = nextNativeE3HomeworldStep(game); assert.ok(next, 'An actual human-owned native window needs its owner.'); + game = actNativeE3Homeworld(game, next, actions); + } + throw Error('Original native Homeworld programme did not reach its requested window.'); +} + +/** Reuse the original native initializer; no parallel game configuration or force staging. */ +export function createNativeE3HomeworldSetup(options: NativeE3HomeworldOptions = {}): NativeE3HomeworldSetup { + const faction = options.faction ?? options.initial?.players.find(p => ['ecaz', 'moritani'].includes(p.faction))?.faction ?? 'ecaz'; + const advanced = options.advanced ?? options.initial?.advanced ?? true; + const skills = options.skills ?? true, discovery = options.discovery ?? false; + const roster = options.roster ?? [faction, 'guild', 'emperor'] as FactionId[]; + let game = options.initial ? structuredClone(options.initial) + : createGame('NATIVEHWE3MODULES', newPlayer(roster[0], roster[0], roster[0]), advanced, ['ecaz']); + if (!options.initial) for (const f of roster.slice(1)) joinGame(game, newPlayer(f, f, f)); + assert.equal(game.status, 'lobby'); assert.equal(game.advanced, advanced); assert.deepEqual(game.expansions, ['ecaz']); + assert.equal(game.players.filter(p => ['ecaz', 'moritani'].includes(p.faction)).length, 1); + assert.ok(game.players.some(p => p.faction === faction)); + assert.ok(game.players.every(p => p.faction === faction || ['guild', 'emperor', 'atreides', 'fremen', 'beneGesserit', + ...(advanced ? [] : ['harkonnen'])].includes(p.faction))); + assert.ok(skills || discovery); assert.ok(!game.nexusCards && !game.ecazTreachery); + if (!game.homeworlds) game = applyAction(game, game.host, { type: 'homeworlds', enabled: true }); + if (!!game.techTokens !== !!options.tech) game = applyAction(game, game.host, { type: 'techTokens', enabled: !!options.tech }); + if (!!game.strongholdCards !== !!options.strongholds) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: !!options.strongholds }); + game.discoveryEnabled = discovery; + const initial = structuredClone(game), actions: NativeE3HomeworldStep[] = []; + const owner = game.players.find(p => p.faction === faction)!.id; + const opponent = game.players.find(p => p.faction === 'guild')?.id ?? game.players.find(p => p.id !== owner)!.id; + const requested = options.skill ?? 'suk-graduate'; + const preferences: LeaderSkillId[] = game.players.map(p => p.id === owner ? options.assassination ? 'warmaster' : requested + : p.id === opponent && options.assassination ? 'suk-graduate' + : p.faction === 'atreides' ? 'bureaucrat' : p.faction === 'fremen' ? 'killer-medic' + : p.faction === 'beneGesserit' ? 'prana-bindu-adept' : p.faction === 'harkonnen' ? 'smuggler' + : p.faction === 'guild' ? 'swordmaster-of-ginaz' : 'master-of-assassins'); + const canonical = LEADER_SKILL_CARDS.map(c => c.id), remainder = canonical.filter(s => !preferences.includes(s)); + const desiredSkills = preferences.flatMap(s => [s, remainder.shift()!]).concat(remainder); + const cards = treacheryDeck(['ecaz']), remaining = [...cards], prefix = game.players.flatMap(p => { + const kinds = [p.id === opponent && options.lethal ? 'projectile' : 'worthless', ...(p.faction === 'harkonnen' ? ['shield'] : [])]; + return kinds.map(kind => { + const match = remaining.findIndex(c => c.kind === kind); + const i = match >= 0 ? match : remaining.findIndex(c => c.kind === 'shield' || c.kind === 'snooper'); + assert.ok(i >= 0); return remaining.splice(i, 1)[0].id; + }); + }); + for (const p of game.players) if (!p.ready) game = actNativeE3Homeworld(game, { actor: p.id, action: { type: 'ready' } }, actions); + const draws = [...(skills ? permutation(canonical, desiredSkills) : []), ...(discovery ? Array(7).fill(0xffffffff) : []), + ...permutation(cards.map(c => c.id), [...prefix, ...remaining.map(c => c.id)])]; + game = lottery(draws, () => skills ? initializeLeaderSkillsGameForAudit(game) : initializeDiscoveryGameForAudit(game)); + const offered = structuredClone(game); + for (let n = 0; game.status === 'setup' && n < 240; n++) { + let step: NativeE3HomeworldStep; + if (game.setupStage === 'leaderSkills') { + const actor = Object.keys(game.leaderSkills!.offers)[0], own = viewGame(game, actor).leaderSkills!; + const skill = preferences[game.players.findIndex(p => p.id === actor)]; + assert.ok(own.offer!.cards.includes(skill) && !own.unavailableSkills?.[skill]); + const eligible = own.eligibleLeaders.map(l => nativeE3HomeworldPlayer(game, actor).leaders.find(d => d.id === l.id)!) + .sort((a, b) => b.strength - a.strength); + const leader = options.assassination && actor === opponent ? eligible.find(l => l.name === 'Master Bewt') : eligible[0]; + assert.ok(leader); + step = { actor, action: { type: 'leaderSkill', event: own.offer!.event, skill, leader: leader.id } }; + if (options.assassination && Object.keys(game.leaderSkills!.offers).length === 1) { + const target = leaders('guild').find(l => l.name === 'Master Bewt')!.id; + const traitors = traitorDeck(game.players), desired = [target, ...traitors.filter(t => t !== target)]; + const ownerIndex = game.players.findIndex(p => p.id === owner); + [desired[0], desired[ownerIndex * 4]] = [desired[ownerIndex * 4], desired[0]]; + game = lottery([...Array(game.leaderSkills!.deck.length).fill(0xffffffff), + ...permutation(traitors, desired)], () => actNativeE3Homeworld(game, step, actions)); + continue; + } + } else if (game.setupStage === 'traitors' && options.assassination && nativeE3HomeworldPlayer(game, owner).traitorChoices.length) { + const target = leaders('guild').find(l => l.name === 'Master Bewt')!.id; + assert.ok(nativeE3HomeworldPlayer(game, owner).traitorChoices.includes(target)); + step = { actor: owner, action: { type: 'traitor', leader: target } }; + } else { const next = nextNativeE3HomeworldStep(game); assert.ok(next); step = next; } + game = actNativeE3Homeworld(game, step, actions); + } + assert.equal(game.status, 'playing'); assertNativeE3HomeworldCustody(game); + return { initial, offered, afterSetup: structuredClone(game), owner, opponent, + trainer: game.leaderSkills?.assignments.find(a => a.owner === owner)?.leader ?? null, actions }; +} +function movement(game: Game, actor: string, actions: NativeE3HomeworldStep[]): Game { + return advanceNativeE3Homeworld(game, g => g.phase === 5 && g.active === actor && nativeE3HomeworldClean(g), actions); +} +function invasion(game: Game, actor: string, destination: string, amount: number): NativeE3HomeworldStep { + const sources = nativeShipmentSources(viewGame(game, actor), amount, 0); assert.ok(sources); + const selected = Object.fromEntries(Object.entries(sources).filter(([, group]) => group.normal + group.elite > 0)); + const choice = homeworldShipmentChoice(viewGame(game, actor), destination, selected); + assert.ok(choice.action, choice.blocked ?? 'Original invasion is unavailable.'); return { actor, action: choice.action }; +} +function arrive(game: Game, actions: NativeE3HomeworldStep[]): Game { + return advanceNativeE3Homeworld(game, g => !g.pendingShipment && !g.pendingHomeworldShipment && nativeE3HomeworldClean(g), actions); +} +export function openNativeE3HomeworldBattle(state: Game, location: string, first: string, second: string, + actions: NativeE3HomeworldStep[] = [], hide = true): Game { + let game = advanceNativeE3Homeworld(state, g => g.phase === 6 && nativeE3HomeworldClean(g) && !!g.active, actions); + assert.ok(game.active === first || game.active === second); + game = actNativeE3Homeworld(game, { actor: game.active!, action: { type: 'chooseBattle', territory: location, + target: game.active === first ? second : first } }, actions); + for (let n = 0; n < 180; n++) { + if (game.battle && nativeE3HomeworldClean(game) && !game.battle.preparation && game.battle.preLeader?.closed !== false) return game; + const next = nextNativeE3HomeworldStep(game); assert.ok(next); + if (next.action.type === 'leaderSkillVisibility') next.action.hide = hide; + game = actNativeE3Homeworld(game, next, actions); + } + throw Error('Original Homeworld Battle Plan window did not open.'); +} +export function createNativeE3HomeworldBattle(options: NativeE3HomeworldOptions & { visitor?: boolean; battleTurn?: 1 | 2 } = {}): NativeE3HomeworldBattle { + const setup = createNativeE3HomeworldSetup(options), visitor = options.visitor ?? false; + const location = `homeworld:${nativeE3HomeworldPlayer(setup.afterSetup, visitor ? setup.opponent : setup.owner).faction}`; + const actor = visitor ? setup.owner : setup.opponent; + let game = structuredClone(setup.afterSetup); + for (let position = 0; position < (game.advanced ? 4 : 2); position++) + frontSpice(game, c => 'territory' in c && !c.discovery, position); + if (options.battleTurn === 2) game = advanceNativeE3Homeworld(game, + g => g.turn === 2 && g.phase === 0 && nativeE3HomeworldClean(g), setup.actions); + game = movement(game, actor, setup.actions); + const beforeShipment = structuredClone(game), shipment = invasion(game, actor, location, 5); + game = arrive(actNativeE3Homeworld(game, shipment, setup.actions), setup.actions); + const afterShipment = structuredClone(game); + game = advanceNativeE3Homeworld(game, g => g.phase === 6 && nativeE3HomeworldClean(g), setup.actions); + const beforeBattle = structuredClone(game); + game = openNativeE3HomeworldBattle(game, location, setup.owner, setup.opponent, setup.actions, !options.assassination); + const trained = new Set(game.leaderSkills?.assignments.map(a => a.leader) ?? []); + const enemy = nativeE3HomeworldPlayer(game, setup.opponent).leaders.filter(l => !l.dead && !trained.has(l.id)) + .sort((a, b) => options.lethal || options.assassination ? b.strength - a.strength : a.strength - b.strength)[0]; + const ownerLeader = options.assassination ? nativeE3HomeworldPlayer(game, setup.owner).leaders.filter(l => !trained.has(l.id)) + .sort((a, b) => a.strength - b.strength)[0].id : setup.trainer ?? nativeE3HomeworldPlayer(game, setup.owner).leaders[0].id; + const weapon = options.lethal ? nativeE3HomeworldPlayer(game, setup.opponent).hand.find(c => c.kind === 'projectile')?.id : undefined; + if (options.lethal) assert.ok(weapon); + const worthless = nativeE3HomeworldPlayer(game, setup.opponent).hand.find(c => c.kind === 'worthless')?.id; + const dial = options.lethal || options.assassination ? 0 : 4; + const plans: NativeE3HomeworldStep[] = [ + { actor: setup.owner, action: { type: 'battlePlan', leader: ownerLeader, dial, support: game.advanced ? dial : 0 } }, + { actor: setup.opponent, action: { type: 'battlePlan', leader: enemy.id, dial: options.assassination ? 3 : 0, + support: game.advanced && options.assassination ? 3 : 0, weapon: weapon ?? worthless } }, + ]; + return { ...setup, visitor, location, beforeShipment, shipment, afterShipment, beforeBattle, game, plans }; +} +export function revealNativeE3HomeworldPlans(state: Game, plans: NativeE3HomeworldStep[], + actions?: NativeE3HomeworldStep[]): Game { + let game = structuredClone(state); + for (const step of plans) { + game = actNativeE3Homeworld(game, step, actions); + if (!game.battle?.revealed) game = advanceNativeE3Homeworld(game, + g => nativeE3HomeworldClean(g) && !!g.battle && !g.battle.preparation && g.battle.preLeader?.closed !== false, actions); + } + assert.ok(game.battle?.revealed); return game; +} +export function settleNativeE3HomeworldBattle(state: Game, stop: 'suk' | 'assassination' | 'tech' | 'complete' = 'complete', + actions?: NativeE3HomeworldStep[]): Game { + return advanceNativeE3Homeworld(state, g => !g.response && !g.phaseOpening && ( + stop === 'suk' && g.decision?.kind === 'sukRescue' || + stop === 'assassination' && g.decision?.kind === 'moritaniAssassinate' || + stop === 'tech' && g.decision?.kind === 'techToken' || !g.battle && nativeE3HomeworldClean(g)), actions); +} +export function rescueNativeE3Homeworld(state: Game): Game { + const d = state.decision; assert.ok(d?.kind === 'sukRescue'); + const maximum = Math.max(...d.options.map(o => o.normal + o.elite)); + const choice = d.options.findIndex(o => o.normal + o.elite === maximum && o.kept?.kind === 'normal'); + assert.ok(choice >= 0); return applyAction(state, d.player, { type: 'decision', event: d.event, choice }); +} + +/** All five deaths and revival are played. Each later visitor arrives by a new + * paid invasion; the original native Guild retains its real winning projectile. */ +export function createNativeE3HomeworldRevival(options: NativeE3HomeworldOptions = {}): NativeE3HomeworldRevival { + const setup = createNativeE3HomeworldSetup({ ...options, skills: true, lethal: true, skill: 'mentat' }); + let game = structuredClone(setup.afterSetup); + for (let position = 0; position < 12; position++) + frontSpice(game, c => 'territory' in c && !c.discovery, position); + const location = 'homeworld:guild', deaths: NativeE3HomeworldDeath[] = []; + const discs = nativeE3HomeworldPlayer(game, setup.owner).leaders.map(l => l.id); + const weapon = nativeE3HomeworldPlayer(game, setup.opponent).hand.find(c => c.kind === 'projectile')!.id; + const winner = nativeE3HomeworldPlayer(game, setup.opponent).leaders.filter(l => + !game.leaderSkills!.assignments.some(a => a.leader === l.id)).sort((a, b) => b.strength - a.strength)[0].id; + for (const leader of discs) { + game = movement(game, setup.owner, setup.actions); + game = arrive(actNativeE3Homeworld(game, invasion(game, setup.owner, location, 1), setup.actions), setup.actions); + game = openNativeE3HomeworldBattle(game, location, setup.owner, setup.opponent, setup.actions); + const before = structuredClone(game); + game = revealNativeE3HomeworldPlans(game, [ + { actor: setup.owner, action: { type: 'battlePlan', leader, dial: 0, support: 0 } }, + { actor: setup.opponent, action: { type: 'battlePlan', leader: winner, dial: 0, support: 0, weapon } }, + ], setup.actions); + game = advanceNativeE3Homeworld(game, g => !g.battle && nativeE3HomeworldClean(g) || g.decision?.kind === 'moritaniAssassinate', setup.actions); + if (game.decision?.kind === 'moritaniAssassinate') { + game = actNativeE3Homeworld(game, { actor: game.decision.player, + action: { type: 'decision', event: game.decision.event, decline: true } }, setup.actions); + game = settleNativeE3HomeworldBattle(game, 'complete', setup.actions); + } + deaths.push({ before, after: structuredClone(game), leader, weapon }); + const turn = game.turn; + game = advanceNativeE3Homeworld(game, g => g.turn === turn + 1 && g.phase === 4 && nativeE3HomeworldClean(g), setup.actions); + } + const revivalWindow = structuredClone(game), leader = setup.trainer!; + const revival: NativeE3HomeworldStep = { actor: setup.owner, action: { type: 'reviveLeader', leader } }; + game = actNativeE3Homeworld(game, revival, setup.actions); + game = advanceNativeE3Homeworld(game, g => !!g.leaderSkills!.offers[setup.owner], setup.actions); + return { ...setup, deaths, revivalWindow, revival, offeredRevival: game }; +} +function frontSpice(game: Game, predicate: (card: Game['spiceDeck'][number]) => boolean, position: number): void { + const i = game.spiceDeck.findIndex((c, n) => n >= position && predicate(c)); assert.ok(i >= position); + game.spiceDeck.splice(position, 0, game.spiceDeck.splice(i, 1)[0]); +} +/** Only unplayed Spice order and the original supply lottery are selected. + * Reveal is followed by next-turn parent entry, not a direct nested placement. */ +export function createNativeE3HomeworldDiscovery(options: NativeE3HomeworldOptions = {}): NativeE3HomeworldDiscovery { + const setup = createNativeE3HomeworldSetup({ ...options, discovery: true }); + let game = structuredClone(setup.afterSetup); + frontSpice(game, c => 'territory' in c && c.discovery === 'discovery-hagga-basin', 0); + for (let position = 1; position < (game.advanced ? 4 : 2); position++) + frontSpice(game, c => 'territory' in c && !c.discovery && c.territory !== 'hagga_basin', position); + const face = 'cistern', type = DISCOVERY_TOKEN_BY_ID[face].type; + const pool = game.discoveries!.tokens.filter(t => t.status === 'supply' && t.type === type); + const index = pool.findIndex(t => t.face === face); assert.ok(index >= 0); + game = advanceNativeE3Homeworld(game, g => g.phase === 1 && nativeE3HomeworldClean(g), setup.actions); + game = lottery([Math.floor((index + 0.5) / pool.length * 0x100000000)], () => + advanceNativeE3Homeworld(game, g => g.discoveries!.tokens.some(t => t.face === face && t.status === 'placed'), setup.actions)); + game = movement(game, setup.owner, setup.actions); + const beforeShipment = structuredClone(game), destination = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin']; + const sources = nativeShipmentSources(viewGame(game, setup.owner), 2, 0); assert.ok(sources); + const shipment: NativeE3HomeworldStep = { actor: setup.owner, action: { type: 'ship', territory: destination.territory, + sector: destination.sector, amount: 2, elite: 0, allyPayment: 0, homeworldSources: sources } }; + game = arrive(actNativeE3Homeworld(game, shipment, setup.actions), setup.actions); + const afterShipment = structuredClone(game); + game = advanceNativeE3Homeworld(game, g => g.phase === 7 && nativeE3HomeworldClean(g), setup.actions); + const beforeReveal = structuredClone(game), token = game.discoveries!.tokens.find(t => t.face === face)!.id; + if (viewGame(game, setup.owner).discoveries!.canInspect.includes(token)) + game = actNativeE3Homeworld(game, { actor: setup.owner, action: { type: 'discovery', token, reveal: false } }, setup.actions); + game = actNativeE3Homeworld(game, { actor: setup.owner, action: { type: 'discovery', token, reveal: true } }, setup.actions); + const afterReveal = structuredClone(game); + game = advanceNativeE3Homeworld(game, g => g.turn === 2 && !g.response && !g.phaseOpening && + g.decision?.kind === 'discoveryEntry' && g.decision.player === setup.owner, setup.actions); + const entryWindow = structuredClone(game), view = viewGame(game, setup.owner); + const action = discoveryEntryMoveAction(view, view.discoveryEntry!.sources); assert.ok(action); + const entry: NativeE3HomeworldStep = { actor: setup.owner, action }; + game = actNativeE3Homeworld(game, entry, setup.actions); + return { ...setup, beforeShipment, shipment, afterShipment, beforeReveal, afterReveal, token, entryWindow, entry, entered: game }; +} + +export type NativeE3HomeworldArrival = { before: Game; step: NativeE3HomeworldStep; after: Game }; +/** Reusable original human shipment boundary, including noncombatant BG entry. */ +export function invadeNativeE3Homeworld(state: Game, actor: string, destination: string, + amount: number): NativeE3HomeworldArrival { + const actions: NativeE3HomeworldStep[] = [], before = movement(state, actor, actions); + const step = invasion(before, actor, destination, amount); + const after = arrive(actNativeE3Homeworld(before, step, actions), actions); + return { before, step, after }; +} diff --git a/tests/homeworld-native-e1e2-modules-runtime.test.ts b/tests/homeworld-native-e1e2-modules-runtime.test.ts new file mode 100644 index 00000000..1e0cca62 --- /dev/null +++ b/tests/homeworld-native-e1e2-modules-runtime.test.ts @@ -0,0 +1,461 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import type { FactionId } from '../game/catalog'; +import { TERRITORIES, territory } from '../game/board'; +import { richeseCards } from '../game/richese-cards'; +import { nativeShipmentSources } from '../game/homeworld-options'; +import { discoveryEntryMoveAction } from '../game/discovery-entry-options'; +import { greatMakerRideAction } from '../game/great-maker-options'; +import { ownedTech } from '../game/tech-tokens'; +import { + advanceNativeHomeworld as advance, createNativeHomeworldModuleSetup as setup, + createNativeHomeworldSukProgramme as sukProgramme, createNativeHomeworldDiscoveryProgramme as discoveryProgramme, + enterNativeHomeworldDiscovery as enter, revealNativeHomeworldDiscovery as reveal, + invadeNativeHomeworld as invade, nativeHomeworldArmy as army, nativeHomeworldClean as clean, + nativeHomeworldInventory as inventory, nativeHomeworldLeader as leader, nativeHomeworldMovement as movement, + nativeHomeworldPlayer as player, openNativeHomeworldBattle as openBattle, + revealNativeHomeworldPlans as plans, settleNativeHomeworldArrival as arrival, + type NativeHomeworldModuleOptions, +} from './fixture-homeworld-native-e1e2-modules'; + +/** Authorized revision PDF21–23 composes variants; exact original native + * component contracts supply abilities. These programmes choose only fresh + * undealt lotteries/unplayed Spice order. No force, wallet, hand, owner, phase, + * signed frame or casualty is staged. Emperor return rulings remain untouched. */ +function reject(game: Game, actor: string, action: Action): void { + const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before); +} +function policies(game: Game, actor: string, relevant: (action: Action) => boolean): void { + for (const difficulty of DIFFICULTIES) { + const view = viewGame(game, actor); view.players.find(p => p.id === actor)!.bot = difficulty; + const action = botActions(view).find(relevant); assert.ok(action, `${difficulty} consumes an owned relevant offer`); + const after = applyAction(structuredClone(game), actor, action); + if (after.status === 'playing') inventory(after); + } +} +const e1Rosters: readonly (readonly FactionId[])[] = [ + ['ixians', 'guild'], ['tleilaxu', 'guild'], ['ixians', 'tleilaxu', 'guild', 'fremen'], +]; +const e2Rosters: readonly (readonly FactionId[])[] = [ + ['choam', 'guild'], ['richese', 'guild'], ['choam', 'richese', 'guild', 'fremen'], +]; + +void test('original Basic/Advanced E1/E2 single and paired rosters compose either or both modules without changing selected 47/35 decks or private native inventories', () => { + for (const advanced of [false, true]) for (const expansions of [['ix'], ['choam']]) + for (const roster of expansions[0] === 'ix' ? e1Rosters : e2Rosters) + for (const [skills, discovery] of [[true, false], [false, true], [true, true]]) { + const f = setup({ advanced, expansions, roster, skills, discovery }); + assert.equal(f.afterSetup.leaderSkills?.assignments.length ?? 0, skills ? roster.length : 0); + assert.equal(f.afterSetup.discoveries?.tokens.length ?? 0, discovery ? 8 : 0); + for (const p of f.afterSetup.players) { + assert.equal(p.hand.length, 1); + assert.equal(viewGame(f.afterSetup, p.id).players.find(q => q.id !== p.id)!.hand, undefined); + } + if (roster.includes('tleilaxu')) { + assert.equal(player(f.afterSetup, 'tleilaxu').faceDancers!.length, 3); + assert.equal(new Set(player(f.afterSetup, 'tleilaxu').faceDancers!.map(c => c.leader)).size, 3); + } + if (roster.includes('richese')) { + assert.deepEqual(f.afterSetup.richeseCache!.map(c => c.id).sort(), richeseCards().map(c => c.id).sort()); + assert.deepEqual(player(f.afterSetup, 'richese').noField!.tokens.map(t => t.value).sort((a, b) => a - b), [0, 3, 5]); + } + if (roster.includes('choam')) assert.equal(player(f.afterSetup, 'choam').leaders.filter(l => l.id === 'choam-auditor').length, advanced ? 1 : 0); + if (f.offered) policies(f.offered, roster[0], a => a.type === 'leaderSkill'); + inventory(f.afterSetup); + } +}); + +void test('supported original Advanced two-deck native rosters retain Ix-selected deck precedence and optional Tech3+/Stronghold2+ components', () => { + const rosters: readonly (readonly FactionId[])[] = [ + ['ixians', 'choam', 'guild'], ['tleilaxu', 'richese', 'guild'], + ['ixians', 'tleilaxu', 'choam', 'richese', 'guild', 'fremen'], + ]; + for (const roster of rosters) for (const [skills, discovery] of [[true, false], [false, true], [true, true]]) { + const f = setup({ advanced: true, expansions: ['ix', 'choam'], roster, skills, discovery, tech: true, strongholds: true }); + assert.ok(f.afterSetup.techTokens && f.afterSetup.strongholdCards); + assert.equal(f.afterSetup.techTokens!.axlotl.owner, roster.includes('tleilaxu') ? 'tleilaxu' : null); + inventory(f.afterSetup); + } +}); + +void test('native and visiting trained/normal Ixian Suk preserve physical saved sources and allow only the real residual Cyborg exchange after rescue', () => { + for (const advanced of [false, true]) for (const visitor of [false, true]) for (const band of ['normal', 'skilled'] as const) { + const f = sukProgramme({ advanced, visitor, band, tech: true, strongholds: advanced }); + const revealed = plans(f.game, f.plans); + let game = advance(revealed, g => g.decision?.kind === (band === 'normal' ? 'ixSubstitution' : 'sukRescue')); + let before = structuredClone(revealed); + if (band === 'skilled') { + const decision = game.decision; assert.ok(decision?.kind === 'sukRescue'); + assert.equal(Math.max(...decision.options.map(o => o.normal + o.elite)), 3); + assert.deepEqual(army(game, f.owner, f.territory), army(f.game, f.owner, f.territory), + 'Own casualty selection has not transported or killed a counter yet.'); + policies(game, f.owner, a => a.type === 'decision' && a.choice !== undefined); + const choice = decision.options.findIndex(o => o.normal === 0 && o.elite === 1 && o.kept?.kind === 'elite'); + assert.ok(choice >= 0); + before = structuredClone(game); + reject(game, f.opponent, { type: 'decision', event: decision.event, choice }); + reject(game, f.owner, { type: 'decision', event: 'stale-suk', choice }); + reject(game, f.owner, { type: 'decision', event: decision.event, choice, + destinations: { [f.territory]: { normal: 1, elite: 1 } } }); + game = applyAction(game, f.owner, { type: 'decision', event: decision.event, choice }); + game = advance(game, g => g.decision?.kind === 'ixSubstitution'); + } + const nativeBefore = army(before, f.owner, 'homeworld:ixians'); + const physical = game.lastBattleContext!.sukRescue!.physical!; + assert.equal(physical.source, visitor ? 'visitor-homeworld' : 'native-homeworld'); + assert.deepEqual(physical.saved, { normal: 0, elite: 1 }); + assert.deepEqual(physical.tanks, { normal: 0, elite: 2 }); + assert.equal(player(game, f.owner).tanks, player(before, f.owner).tanks + 2); + assert.equal(player(game, f.owner).elites!.tanks, player(before, f.owner).elites!.tanks + 2); + if (visitor && band === 'normal') { + assert.deepEqual(physical.destinations, { 'homeworld:ixians': { normal: 0, elite: 1 } }); + assert.deepEqual(army(game, f.owner, 'homeworld:ixians'), { ...nativeBefore, elite: nativeBefore.elite + 1 }); + } else assert.deepEqual(physical.destinations, {}); + assert.deepEqual(game.pendingIxSubstitution!.losses, { [f.territory]: 2 }); + const substitution = structuredClone(game), beforeArmy = army(game, f.owner, f.territory); + const action: Action = { type: 'decision', sources: { [f.territory]: 1 }, recover: { [f.territory]: 1 } }; + reject(game, f.owner, { ...action, recover: { [f.territory]: 3 } }); + reject(game, f.owner, { ...action, sources: { reserves: 1 } }); + reject(game, f.owner, { ...action, recover: { 'hidden_mobile_stronghold:0': 1 } }); + policies(game, f.owner, a => a.type === 'decision' && (a.decline === true || a.sources !== undefined)); + game = applyAction(game, f.owner, action); + game = advance(game, g => !g.pendingIxSubstitution); + assert.deepEqual(army(game, f.owner, f.territory), { normal: beforeArmy.normal - 1, elite: beforeArmy.elite + 1 }); + assert.equal(player(game, f.owner).tanks, player(substitution, f.owner).tanks); + assert.equal(player(game, f.owner).elites!.tanks, player(substitution, f.owner).elites!.tanks - 1); + assert.equal(player(game, f.owner).battleLosses, player(substitution, f.owner).battleLosses); + assert.equal(player(game, f.owner).spice, player(substitution, f.owner).spice); + assert.deepEqual(game.players.map(p => [p.id, p.spice]), substitution.players.map(p => [p.id, p.spice]), + 'Substitution creates neither a second support payment nor a bounty for any faction.'); + assert.deepEqual(player(game, f.owner).hand, player(substitution, f.owner).hand); + assert.deepEqual(game.discard, substitution.discard); + assert.deepEqual(game.leaderSkills!.assignments, substitution.leaderSkills!.assignments); + if (visitor) assert.deepEqual(army(game, f.owner, 'homeworld:ixians'), army(substitution, f.owner, 'homeworld:ixians')); + reject(game, f.owner, action); inventory(game); + game = advance(game, g => !g.battle && !g.pendingIxSubstitution && !g.decision && !g.response && !g.pendingTreacheryDiscard); + assert.equal(game.lastBattleContext!.winner, f.owner); + assert.equal(game.lastBattleContext!.sukRescue!.completed, true); + assert.equal(game.lastBattleContext!.ixSubstitution!.completed, true); + assert.ok(ownedTech(game.techTokens!, f.owner).includes('heighliners')); + inventory(game); + } +}); + +void test('genuine maximum trained Cyborg rescue opens no exchange for saved casualties and cannot be claimed twice', () => { + const f = sukProgramme({ advanced: true, visitor: true, band: 'skilled', tech: true }); + let game = advance(plans(f.game, f.plans), g => g.decision?.kind === 'sukRescue'); + const d = game.decision; assert.ok(d?.kind === 'sukRescue'); + const choice = d.options.findIndex(o => o.elite === 3 && o.normal === 0 && o.kept?.kind === 'elite'); assert.ok(choice >= 0); + const action: Action = { type: 'decision', event: d.event, choice }; + game = applyAction(game, f.owner, action); + assert.equal(player(game, f.owner).elites!.tanks, 0); + assert.deepEqual(army(game, f.owner, f.territory), { normal: 4, elite: 1 }); + assert.deepEqual(army(game, f.owner, 'homeworld:ixians'), { normal: 6, elite: 3 }); + reject(game, f.owner, action); inventory(game); +}); + +void test('native E1/E2 Discovery inspection and later typed parent entry move only original shipped counters without borrowing native reserves', () => { + for (const advanced of [false, true]) for (const family of ['ix', 'choam']) for (const skills of [false, true]) { + const options: NativeHomeworldModuleOptions = family === 'ix' + ? { roster: ['ixians', 'tleilaxu', 'guild', 'fremen'], expansions: ['ix'] } + : { roster: ['richese', 'choam', 'guild', 'fremen'], expansions: ['choam'], skill: 'planetologist' }; + const f = discoveryProgramme({ ...options, advanced, skills, face: 'cistern', tech: true, strongholds: advanced }); + const key = `${f.parent}:${f.parentSector}`; + assert.equal(player(f.game, f.owner).forces[key], 2); + assert.equal(player(f.game, f.owner).reserves, player(f.beforeShipment, f.owner).reserves - 2); + const unveiled = reveal(f); + assert.equal(unveiled.discoveries!.tokens.find(t => t.id === f.token)!.revealedTurn, 1); + assert.equal(player(unveiled, f.owner).forces['cistern:0'] ?? 0, 0, 'Reveal never creates an army.'); + const window = advance(unveiled, g => g.turn === 2 && g.decision?.kind === 'discoveryEntry' && g.decision.player === f.owner); + policies(window, f.owner, a => a.type === 'decision'); + const view = viewGame(window, f.owner), action = discoveryEntryMoveAction(view, view.discoveryEntry!.sources); assert.ok(action); + reject(window, f.owner, { ...action, sources: { [`homeworld:${f.owner}`]: { normal: 2, elite: 0 } } }); + const homes = structuredClone(window.homeworlds!.custody), before = structuredClone(player(window, f.owner)); + const after = applyAction(window, f.owner, action); + assert.equal(player(after, f.owner).forces[key] ?? 0, 0); + assert.equal(player(after, f.owner).forces['cistern:0'], 2); + assert.equal(player(after, f.owner).elites?.forces['cistern:0'] ?? 0, family === 'ix' ? 1 : 0); + assert.deepEqual(after.homeworlds!.custody, homes); + assert.equal(player(after, f.owner).spice, before.spice); + assert.equal(player(after, f.owner).moved, before.moved); + assert.equal(player(after, f.owner).shipped, before.shipped); + assert.deepEqual(after.leaderSkills, window.leaderSkills); inventory(after); + } +}); + +void test('original native Discovery Great Maker votes precede a free typed Fremen reserve ride with original Homeworld custody', () => { + for (const advanced of [false, true]) { + const f = discoveryProgramme({ advanced, maker: true, face: 'cistern', tech: true }); + let game = advance(f.game, g => g.decision?.kind === 'greatMakerVote'); + for (const actor of game.order) { + assert.ok(game.decision?.kind === 'greatMakerVote'); assert.equal(game.decision.player, actor); + policies(game, actor, a => a.type === 'decision'); + game = applyAction(game, actor, { type: 'decision', event: game.decision.event, yes: false }); + } + assert.ok(game.decision?.kind === 'greatMakerRide'); assert.equal(game.decision.player, 'fremen'); + const view = viewGame(game, 'fremen'), destination = view.greatMaker!.ride!.destinations.find(d => d.territory === 'polar_sink'); assert.ok(destination); + policies(game, 'fremen', a => a.type === 'decision'); + const elite = advanced ? 1 : 0, action = greatMakerRideAction(view, destination.territory, destination.sector, 2, elite); assert.ok(action); + reject(game, 'fremen', { ...action, territory: 'homeworld:ixians', sector: 0 }); + const before = structuredClone(player(game, 'fremen')), training = structuredClone(game.leaderSkills); + game = applyAction(game, 'fremen', action); + assert.equal(player(game, 'fremen').reserves, before.reserves - 2); + assert.equal(player(game, 'fremen').elites!.reserves, before.elites!.reserves - elite); + assert.equal(player(game, 'fremen').forces['polar_sink:0'], (before.forces['polar_sink:0'] ?? 0) + 2); + assert.equal(player(game, 'fremen').spice, before.spice); + assert.deepEqual(game.leaderSkills, training); inventory(game); + } +}); + +void test('original Richese marker retains its signed token, charges one invoice and materializes only real native reserves at a revealed nested Discovery', () => { + for (const advanced of [false, true]) { + const f = discoveryProgramme({ advanced, expansions: ['choam'], roster: ['richese', 'choam', 'guild', 'fremen'], + collector: 'guild', skill: 'planetologist', face: 'shrine', tech: true, strongholds: advanced }); + const entered = enter(f); + let game = movement(entered, 'richese', 2); + const p = player(game, 'richese'), marker = p.noField!.tokens.find(t => t.value === 5)!; + const before = structuredClone(game), invoice: Action = { type: 'ship', territory: 'shrine', sector: 0, + noField: marker.id, event: p.noFieldEvent, allyPayment: 0 }; + reject(game, 'richese', { ...invoice, event: 'stale-marker' }); + game = arrival(applyAction(game, 'richese', invoice)); + assert.equal(player(game, 'richese').reserves, player(before, 'richese').reserves); + assert.equal(player(game, 'richese').forces['shrine:0'] ?? 0, 0); + assert.equal(player(game, 'richese').noField!.deployed!.tokenId, marker.id); + assert.equal(player(before, 'richese').spice - player(game, 'richese').spice, 1); + assert.equal(player(game, 'guild').spice - player(before, 'guild').spice, 1); + reject(game, 'richese', invoice); + const deployed = structuredClone(game), event = player(game, 'richese').noFieldEvent; + game = applyAction(game, 'richese', { type: 'revealNoField', token: marker.id, event }); + assert.equal(player(game, 'richese').forces['shrine:0'], 5); + assert.equal(player(game, 'richese').reserves, player(deployed, 'richese').reserves - 5); + assert.equal(player(game, 'richese').spice, player(deployed, 'richese').spice); + assert.equal(player(game, 'guild').spice, player(deployed, 'guild').spice); + assert.equal(player(game, 'richese').noField!.lastShipped, marker.id); + assert.equal(player(game, 'richese').noField!.deployed, null); + inventory(game); + } +}); + +void test('real repeated original Richese marker shipments cap the final printed five at the four physical reserves left, never inventing a fifth counter', () => { + let game = setup({ advanced: true, skills: false, expansions: ['choam'], roster: ['richese', 'choam', 'guild'] }).afterSetup; + for (const [index, value] of [5, 3, 5, 3, 5].entries()) { + game = movement(game, 'richese', index + 1); + const t = ['sietch_tabr', 'habbanya_ridge_sietch'].map(id => territory(id)) + .find(candidate => !candidate.sectors.includes(game.storm)); assert.ok(t); + const sector = t.sectors[0]; + const before = structuredClone(player(game, 'richese')), token = before.noField!.tokens.find(t => t.value === value)!; + game = arrival(applyAction(game, 'richese', { type: 'ship', territory: t.id, sector, + noField: token.id, event: before.noFieldEvent, allyPayment: 0 })); + const deployed = structuredClone(player(game, 'richese')); + game = applyAction(game, 'richese', { type: 'revealNoField', token: token.id, event: deployed.noFieldEvent }); + const physical = Math.min(value, before.reserves); + assert.equal(player(game, 'richese').forces[`${t.id}:${sector}`], (before.forces[`${t.id}:${sector}`] ?? 0) + physical); + assert.equal(player(game, 'richese').reserves, before.reserves - physical); + assert.equal(player(game, 'richese').spice, deployed.spice); + if (index === 4) { assert.equal(physical, 4); assert.equal(player(game, 'richese').reserves, 0); } + inventory(game); + } +}); + +void test('real CHOAM low-home shipment and Advanced paid battle support charge once without bypassing original income or reserve pools', () => { + const f = setup({ advanced: true, expansions: ['choam'], roster: ['choam', 'richese', 'guild', 'fremen'], + skill: 'smuggler', tech: true, strongholds: true }); + let game = advance(f.afterSetup, g => g.turn === 1 && g.phase === 5 && clean(g)); + const destination = TERRITORIES.find(t => t.type === 'stronghold' && t.id !== 'tueks_sietch' && + !t.sectors.includes(game.storm) && t.id !== 'sietch_tabr'); assert.ok(destination); + const remaining = new Set(['choam', 'guild']); + while (remaining.size) { + game = advance(game, g => g.turn === 1 && g.phase === 5 && clean(g) && remaining.has(g.active!)); + const actor = game.active!, amount = actor === 'choam' ? 10 : 2; + const before = structuredClone(game), sources = nativeShipmentSources(viewGame(game, actor), amount, 0); assert.ok(sources); + game = arrival(applyAction(game, actor, { type: 'ship', territory: destination.id, sector: destination.sectors[0], + amount, elite: 0, allyPayment: 0, homeworldSources: sources })); + if (actor === 'choam') { + assert.equal(player(game, actor).reserves, 10); + assert.equal(player(before, actor).spice - player(game, actor).spice, 10); + assert.equal(player(game, 'guild').spice - player(before, 'guild').spice, 10); + assert.deepEqual(army(game, actor, 'homeworld:choam'), { normal: 10, elite: 0 }); + } + remaining.delete(actor); + if (remaining.size) game = applyAction(game, actor, { type: 'endMovement' }); + } + game = openBattle(game, 'choam', 'guild', destination.id, ''); + const beforePlans = structuredClone(game); + game = plans(game, [{ actor: 'choam', action: { type: 'battlePlan', leader: leader(game, 'choam', true), dial: 1, support: 1 } }, + { actor: 'guild', action: { type: 'battlePlan', leader: leader(game, 'guild'), dial: 0, support: 0 } }]); + game = advance(game, g => !g.battle && clean(g) && !g.pendingTreacheryDiscard); + assert.equal(game.lastBattleContext!.winner, 'choam'); + assert.equal(player(game, 'choam').tanks, player(beforePlans, 'choam').tanks + 1); + assert.equal(player(game, 'choam').forces[`${destination.id}:${destination.sectors[0]}`], 9); + assert.equal(player(game, 'choam').leaders.some(l => l.id === 'choam-auditor'), true); + inventory(game); +}); + +void test('original Tleilaxu native death returns training once, real revival offers native discs only, and actual foreign ghola pays without moving the physical owner', () => { + for (const nativeTrainer of [false, true]) { + const f = setup({ advanced: true, expansions: ['ix'], roster: ['tleilaxu', 'guild', 'fremen'], + skill: 'prana-bindu-adept', startingKinds: ['projectile', 'projectile', 'worthless'], tech: true }); + let game = movement(f.afterSetup, 'guild'); + game = invade(game, 'guild', 'homeworld:tleilaxu', 3); + game = openBattle(game, 'tleilaxu', 'guild', 'homeworld:tleilaxu', nativeTrainer ? 'tleilaxu' : ''); + const trained = game.leaderSkills!.assignments.find(a => a.owner === 'tleilaxu')!; + const own = nativeTrainer ? trained.leader : leader(game, 'tleilaxu', true), foreign = leader(game, 'guild'); + const ownWeapon = player(game, 'tleilaxu').hand.find(c => c.kind === 'projectile')!.id; + const foreignWeapon = player(game, 'guild').hand.find(c => c.kind === 'projectile')!.id; + game = plans(game, [{ actor: 'tleilaxu', action: { type: 'battlePlan', leader: own, dial: 1, support: 1, weapon: ownWeapon } }, + { actor: 'guild', action: { type: 'battlePlan', leader: foreign, dial: 0, support: 0, weapon: foreignWeapon } }]); + game = advance(game, g => !g.battle && clean(g) && !g.pendingTreacheryDiscard); + assert.equal(player(game, 'tleilaxu').leaders.find(l => l.id === own)!.dead, true); + assert.equal(player(game, 'guild').leaders.find(l => l.id === foreign)!.dead, true); + assert.equal(game.leaderSkills!.assignments.some(a => a.leader === trained.leader), !nativeTrainer); + game = advance(game, g => g.turn === 2 && g.phase === 4 && clean(g)); + if (nativeTrainer) { + const before = structuredClone(player(game, 'tleilaxu')); + game = applyAction(game, 'tleilaxu', { type: 'reviveLeader', leader: own }); + game = advance(game, g => !!g.leaderSkills!.offers.tleilaxu && !g.response); + assert.equal(player(game, 'tleilaxu').leaders.find(l => l.id === own)!.dead, false); + assert.ok(player(game, 'tleilaxu').spice < before.spice); + policies(game, 'tleilaxu', a => a.type === 'leaderSkill'); + assert.ok(viewGame(game, 'tleilaxu').leaderSkills!.eligibleLeaders.some(l => l.id === own)); + } else { + const before = structuredClone(game); + reject(game, 'guild', { type: 'reviveForeignGhola', leader: foreign }); + game = applyAction(game, 'tleilaxu', { type: 'reviveForeignGhola', leader: foreign }); + game = advance(game, g => !g.pendingRevival && !g.response && !g.decision); + const ghola = player(game, 'guild').leaders.find(l => l.id === foreign)!; + assert.equal(ghola.dead, false); assert.equal(ghola.gholaBy, 'tleilaxu'); + assert.equal(player(game, 'tleilaxu').leaders.some(l => l.id === foreign), false); + assert.ok(player(game, 'tleilaxu').spice < player(before, 'tleilaxu').spice); + assert.deepEqual(game.leaderSkills, before.leaderSkills); + assert.ok(!viewGame(game, 'tleilaxu').leaderSkills!.eligibleLeaders.some(l => l.id === foreign)); + assert.deepEqual(game.homeworlds!.custody, before.homeworlds!.custody); + } + inventory(game); + } +}); + +void test('actual native Homeworld forbids a visiting Tleilaxu Face Dance even with the genuinely drawn matching trainer identity', () => { + const f = sukProgramme({ advanced: true, visitor: false, band: 'skilled', tech: true }); + assert.ok(player(f.afterSetup, 'tleilaxu').faceDancers!.some(c => c.leader === f.trainer && !c.revealed)); + let game = advance(plans(f.game, f.plans), g => g.decision?.kind === 'sukRescue'); + const d = game.decision; assert.ok(d?.kind === 'sukRescue'); + game = applyAction(game, f.owner, { type: 'decision', event: d.event, choice: 0 }); + game = advance(game, g => !g.battle && clean(g) && g.phase === 7); + assert.notEqual(game.decision?.kind, 'faceDance'); + assert.equal(player(game, 'tleilaxu').faceDancers!.find(c => c.leader === f.trainer)!.revealed, false); + const protectedArmy = army(game, f.owner, f.territory); + reject(game, 'tleilaxu', { type: 'decision', reveal: true, sources: { reserves: 1 }, sector: 0 }); + assert.equal(player(game, f.owner).leaders.find(l => l.id === f.trainer)!.dead, false); + assert.deepEqual(army(game, f.owner, f.territory), protectedArmy); + inventory(game); +}); + +void test('genuine Shrine armies retain earned Stronghold ownership and complete original card cleanup and Tech reward before a matching Face Dance', () => { + const f = discoveryProgramme({ advanced: true, face: 'shrine', tech: true, strongholds: true }); + let game = movement(enter(f), 'tleilaxu', 2); + const sources = nativeShipmentSources(viewGame(game, 'tleilaxu'), 2, 0); assert.ok(sources); + game = arrival(applyAction(game, 'tleilaxu', { type: 'ship', territory: 'shrine', sector: 0, + amount: 2, elite: 0, allyPayment: 0, homeworldSources: sources })); + game = openBattle(game, 'ixians', 'tleilaxu', 'shrine'); + const trainer = game.leaderSkills!.assignments.find(a => a.owner === 'ixians')!.leader; + assert.ok(player(game, 'tleilaxu').faceDancers!.some(c => c.leader === trainer && !c.revealed)); + assert.equal(game.strongholdCards!.owners.hidden_mobile_stronghold, 'ixians'); + const weapon = player(game, 'ixians').hand.find(c => c.kind === 'worthless')!.id; + game = plans(game, [{ actor: 'ixians', action: { type: 'battlePlan', leader: trainer, dial: 0, support: 0, weapon } }, + { actor: 'tleilaxu', action: { type: 'battlePlan', leader: leader(game, 'tleilaxu'), dial: 0, support: 0 } }]); + game = advance(game, g => g.decision?.kind === 'battleCards'); + assert.equal(game.techTokens!.axlotl.owner, 'tleilaxu', 'The actual card cleanup still precedes the reward.'); + policies(game, 'ixians', a => a.type === 'decision' && a.discard !== undefined); + game = applyAction(game, 'ixians', { type: 'decision', discard: [] }); + game = advance(game, g => g.decision?.kind === 'faceDance'); + assert.equal(game.techTokens!.axlotl.owner, 'ixians'); + const before = structuredClone(game), prior = structuredClone(player(game, 'ixians')); + const action: Action = { type: 'decision', reveal: true, sources: { reserves: 1 }, sector: 0 }; + reject(game, 'ixians', action); + reject(game, 'tleilaxu', { ...action, sources: { reserves: 3 } }); + policies(game, 'tleilaxu', a => a.type === 'decision' && a.reveal !== undefined); + game = applyAction(game, 'tleilaxu', action); + assert.equal(player(game, 'ixians').forces['shrine:0'] ?? 0, 0); + assert.equal(player(game, 'ixians').reserves, prior.reserves + 2); + assert.equal(player(game, 'ixians').elites!.reserves, prior.elites!.reserves + 1); + assert.equal(player(game, 'tleilaxu').forces['shrine:0'], 1); + assert.equal(player(game, 'tleilaxu').reserves, player(before, 'tleilaxu').reserves - 1); + assert.equal(player(game, 'ixians').leaders.find(l => l.id === trainer)!.dead, true); + assert.equal(game.leaderSkills!.assignments.some(a => a.leader === trainer), false); + assert.equal(game.leaderSkills!.deck.filter(id => id === 'suk-graduate').length, 1); + assert.deepEqual(game.techTokens, before.techTokens); + assert.deepEqual(player(game, 'ixians').hand, prior.hand); + assert.equal(player(game, 'ixians').spice, prior.spice); + assert.equal(player(game, 'ixians').tanks, prior.tanks); + assert.equal(player(game, 'tleilaxu').spice, player(before, 'tleilaxu').spice); + assert.equal(player(game, 'tleilaxu').faceDancers!.find(c => c.leader === trainer)!.revealed, true); + inventory(game); +}); + +void test('original trained Richese ordinary arrival retains one nested invoice and original marker custody', () => { + const f = discoveryProgramme({ advanced: true, expansions: ['choam'], roster: ['richese', 'choam', 'guild', 'fremen'], + collector: 'richese', face: 'cistern', skill: 'planetologist', tech: true, strongholds: true }); + let game = movement(enter(f), 'richese', 2); + const before = structuredClone(game), sources = nativeShipmentSources(viewGame(game, 'richese'), 1, 0); assert.ok(sources); + assert.equal(player(game, 'richese').forces['cistern:0'], 2); + const action: Action = { type: 'ship', territory: 'cistern', sector: 0, amount: 1, elite: 0, + allyPayment: 0, homeworldSources: sources }; + game = arrival(applyAction(game, 'richese', action)); + assert.equal(player(game, 'richese').forces['cistern:0'], 3); + assert.equal(player(game, 'richese').reserves, player(before, 'richese').reserves - 1); + assert.equal(player(game, 'richese').spice, player(before, 'richese').spice - 1); + assert.equal(player(game, 'guild').spice, player(before, 'guild').spice + 1); + assert.equal(player(game, 'richese').noField!.deployed, null); + assert.deepEqual(player(game, 'richese').noField!.tokens, player(before, 'richese').noField!.tokens); + reject(game, 'richese', action); inventory(game); +}); + +void test('actually earned Arrakeen support subsidy applies only at its held source, not a low native CHOAM Homeworld battle', () => { + const balanceChanges: number[] = []; + for (const nativeBattle of [false, true]) { + const f = setup({ advanced: true, expansions: ['choam'], roster: ['choam', 'richese', 'guild', 'fremen'], + skill: 'smuggler', tech: true, strongholds: true }); + let game = movement(f.afterSetup, 'choam'); + const sources = nativeShipmentSources(viewGame(game, 'choam'), 8, 0); assert.ok(sources); + game = arrival(applyAction(game, 'choam', { type: 'ship', territory: 'arrakeen', sector: 10, + amount: 8, elite: 0, smuggler: true, allyPayment: 0, homeworldSources: sources })); + assert.equal(game.strongholdCards!.owners.arrakeen, null); + game = advance(game, g => g.turn === 2 && g.phase === 5 && clean(g)); + assert.equal(game.strongholdCards!.owners.arrakeen, 'choam'); + assert.equal(game.strongholdCards!.claimedTurn, 1, 'Only original end-Mentat claims the physical card.'); + const destination = nativeBattle ? 'homeworld:choam' : 'arrakeen'; + const remaining = new Set(['choam', 'guild']); + while (remaining.size) { + game = advance(game, g => g.turn === 2 && g.phase === 5 && clean(g) && remaining.has(g.active!)); + const actor = game.active!; + if (actor === 'choam') { + const outward = nativeShipmentSources(viewGame(game, actor), 3, 0); assert.ok(outward); + game = arrival(applyAction(game, actor, { type: 'ship', territory: 'carthag', sector: 11, + amount: 3, elite: 0, smuggler: true, allyPayment: 0, homeworldSources: outward })); + assert.equal(player(game, actor).reserves, 9); + } else if (nativeBattle) game = invade(game, actor, destination, 2); + else { + const enemy = nativeShipmentSources(viewGame(game, actor), 2, 0); assert.ok(enemy); + game = arrival(applyAction(game, actor, { type: 'ship', territory: destination, sector: 10, + amount: 2, elite: 0, allyPayment: 0, homeworldSources: enemy })); + } + remaining.delete(actor); + if (remaining.size) game = applyAction(game, actor, { type: 'endMovement' }); + } + game = openBattle(game, 'choam', 'guild', destination, ''); + const before = structuredClone(game); + game = plans(game, [{ actor: 'choam', action: { type: 'battlePlan', leader: leader(game, 'choam', true), dial: 1, support: 1 } }, + { actor: 'guild', action: { type: 'battlePlan', leader: leader(game, 'guild'), dial: 0, support: 0 } }]); + game = advance(game, g => !g.battle && clean(g) && !g.pendingTreacheryDiscard); + assert.equal(game.lastBattleContext!.winner, 'choam'); + balanceChanges.push(player(game, 'choam').spice - player(before, 'choam').spice); + assert.equal(player(game, 'choam').tanks, player(before, 'choam').tanks + 1); + assert.equal(game.strongholdCards!.owners.arrakeen, 'choam'); + assert.equal(player(game, 'choam').forces['arrakeen:10'], nativeBattle ? 8 : 7); + inventory(game); + } + assert.equal(balanceChanges[0] - balanceChanges[1], 1, 'Only the held Arrakeen source subsidizes support; identical later stronghold income is independent.'); +}); diff --git a/tests/homeworld-native-e3-modules-runtime.test.ts b/tests/homeworld-native-e3-modules-runtime.test.ts new file mode 100644 index 00000000..e582d0a0 --- /dev/null +++ b/tests/homeworld-native-e3-modules-runtime.test.ts @@ -0,0 +1,267 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + applyAction, createGame, initializeDiscoveryGameForAudit, initializeLeaderSkillsGameForAudit, + joinGame, newPlayer, viewGame, type Action, type Game, +} from '../game/engine'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import type { FactionId } from '../game/catalog'; +import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards'; +import { ecazOccupancyRelation } from '../game/ecaz-occupy'; +import { ownedTech } from '../game/tech-tokens'; +import { DUKE_VIDAL_ID } from '../game/duke-vidal'; +import { + advanceNativeE3Homeworld as advance, assertNativeE3HomeworldCustody as custody, + createNativeE3HomeworldBattle as battle, createNativeE3HomeworldDiscovery as discovery, + createNativeE3HomeworldRevival as revival, createNativeE3HomeworldSetup as setup, + invadeNativeE3Homeworld as invade, + nativeE3HomeworldClean as clean, nativeE3HomeworldPlayer as player, + nativeE3HomeworldPolicy as policy, nativeE3HomeworldPool as pool, + rescueNativeE3Homeworld as rescue, revealNativeE3HomeworldPlans as reveal, + settleNativeE3HomeworldBattle as settle, type NativeE3HomeworldOptions, +} from './fixture-homeworld-native-e3-modules'; + +function legalPolicies(game: Game, actor: string): void { + for (const difficulty of DIFFICULTIES) { + const actions = policy(game, actor, difficulty); assert.ok(actions.length, `${difficulty} has an actual owned offer`); + for (const action of actions) { + const next = applyAction(structuredClone(game), actor, action); + if (next.homeworlds?.custody) custody(next); + } + } +} +function reject(game: Game, actor: string, action: Action, reason?: RegExp): void { + const before = structuredClone(game); + if (reason) assert.throws(() => applyAction(game, actor, action), reason); + else assert.throws(() => applyAction(game, actor, action)); + assert.deepEqual(game, before); +} + +void test('original standalone Ecaz or Moritani Homeworld module envelopes use native Ecaz33, five discs, all14 before faction setup and DS7+8 at supported seat boundaries', () => { + const modules: Pick[] = [ + { skills: true, discovery: false }, { skills: false, discovery: true }, { skills: true, discovery: true }, + ]; + for (const faction of ['ecaz', 'moritani'] as const) for (const advanced of [false, true]) { + for (const composition of modules) for (const tech of [false, true]) for (const strongholds of advanced ? [false, true] : [false]) { + for (const count of [tech ? 3 : 2, 6]) { + const roster: FactionId[] = [faction, 'guild', 'emperor', 'atreides', 'fremen', 'beneGesserit']; + const lobby = createGame('ORIGINALNATIVEHWE3', newPlayer('native-original', 'Original native', faction), advanced, ['ecaz']); + for (const [i, f] of roster.slice(1, count).entries()) joinGame(lobby, newPlayer(`original-${i}`, `Original ${f}`, f)); + const f = setup({ initial: lobby, faction, advanced, ...composition, tech, strongholds }); + const own = player(f.afterSetup, f.owner); + assert.equal(own.reserves, 14); assert.equal(Object.values(own.forces).reduce((a, b) => a + b, 0), 6); + assert.equal(pool(f.afterSetup, f.owner, `homeworld:${faction}`).normal, 14); + assert.equal(own.leaders.length, 5); assert.equal(f.afterSetup.dukeVidal!.leader.id, DUKE_VIDAL_ID); + if (composition.skills) { + assert.equal(f.afterSetup.leaderSkills!.assignments.length, count); + assert.deepEqual([...f.afterSetup.leaderSkills!.deck, ...f.afterSetup.leaderSkills!.assignments.map(a => a.skill)].sort(), + LEADER_SKILL_CARDS.map(c => c.id).sort()); + } else assert.equal(f.afterSetup.leaderSkills, undefined); + if (composition.discovery) { + const spice = [...f.afterSetup.spiceDeck, ...f.afterSetup.spiceDiscard.flat()]; + assert.equal(spice.filter(c => 'territory' in c && !!c.discovery).length, 6); + assert.equal(spice.filter(c => 'worm' in c && c.greatMaker).length, 1); + assert.equal(f.afterSetup.discoveries!.tokens.length, 8); + } else assert.equal(f.afterSetup.discoveries, undefined); + if (strongholds) assert.ok(Object.values(f.afterSetup.strongholdCards!.owners).every(o => o === null)); + assert.equal(!!f.afterSetup.moritaniAssassinate, advanced && faction === 'moritani'); + if (faction === 'ecaz') { + assert.equal(f.afterSetup.ecazAmbassadors!.tokens.length, 11); + assert.equal(f.afterSetup.ecazAmbassadors!.tokens.filter(t => t.zone === 'supply').length, 6); + } else assert.equal(f.afterSetup.moritaniTerror!.tokens.length, 6); + custody(f.afterSetup); + } + } + } +}); + +for (const faction of ['ecaz', 'moritani'] as const) for (const advanced of [false, true]) { + void test(`${advanced ? 'Advanced' : 'Basic'} ${faction} native and visitor Suk consume real casualties and preserve owner-labelled native/visitor pools`, () => { + for (const visitor of [false, true]) { + const f = battle({ faction, advanced, visitor, skills: true, discovery: true, tech: true, strongholds: advanced }); + const shipper = visitor ? f.owner : f.opponent, native = visitor ? f.opponent : f.owner; + assert.equal(player(f.afterShipment, shipper).reserves, player(f.beforeShipment, shipper).reserves - 5); + assert.equal(pool(f.afterShipment, shipper, f.location).normal, 5); + assert.deepEqual(pool(f.afterShipment, native, f.location), pool(f.beforeShipment, native, f.location)); + assert.ok(player(f.afterShipment, shipper).spice < player(f.beforeShipment, shipper).spice); + assert.ok(!Object.keys(player(f.afterShipment, shipper).forces).some(key => key.startsWith('homeworld:'))); + legalPolicies(f.beforeShipment, shipper); legalPolicies(f.game, f.owner); + const revealed = reveal(f.game, f.plans), pending = settle(revealed, 'suk'); + assert.ok(pending.decision?.kind === 'sukRescue'); + assert.equal(pending.decision.player, f.owner); assert.equal(pending.pendingSukRescue!.skill.mode, 'skilled'); + assert.equal(pending.pendingSukRescue!.territory, f.location); + legalPolicies(pending, f.owner); + const beforePool = pool(pending, f.owner, f.location), beforeReserve = player(pending, f.owner).reserves; + const beforeTanks = player(pending, f.owner).tanks; + const maximum = Math.max(...pending.decision.options.map(o => o.normal + o.elite)); + const choice = pending.decision.options.findIndex(o => o.normal + o.elite === maximum && o.kept?.kind === 'normal'); + const option = pending.decision.options[choice], losses = pending.pendingSukRescue!.losses!; + reject(pending, f.owner, { type: 'decision', event: pending.decision.event, choice, + destinations: { 'homeworld:emperor': { normal: option.normal, elite: 0 } } }); + const done = settle(rescue(structuredClone(pending))); + assert.equal(done.lastBattleContext?.winner, f.owner); + assert.equal(player(done, f.owner).tanks - beforeTanks, losses.normal + losses.elite - option.normal - option.elite); + const afterPool = pool(done, f.owner, f.location); + if (visitor) { + assert.equal(afterPool.normal, beforePool.normal - losses.normal + 1); + assert.equal(player(done, f.owner).reserves, beforeReserve + option.normal - 1); + assert.equal(pool(done, f.owner, `homeworld:${faction}`).normal, player(done, f.owner).reserves); + } else assert.equal(afterPool.normal, beforePool.normal - losses.normal + option.normal); + assert.equal(done.pendingSukRescue ?? null, null); custody(done); + } + }); + + void test(`${advanced ? 'Advanced' : 'Basic'} ${faction} lethal native and visitor Homeworld plans kill the trained disc and return exactly one physical skill`, () => { + for (const visitor of [false, true]) { + const f = battle({ faction, advanced, visitor, lethal: true, skill: 'mentat', discovery: true }); + const original = player(f.game, f.owner).leaders.find(l => l.id === f.trainer)!; + const done = settle(reveal(f.game, f.plans)); + assert.equal(player(done, f.owner).leaders.find(l => l.id === f.trainer)!.dead, true); + assert.equal(player(done, f.owner).leaders.find(l => l.id === f.trainer)!.deaths, original.deaths + 1); + assert.equal(done.leaderSkills!.assignments.some(a => a.leader === f.trainer), false); + assert.equal(done.leaderSkills!.deck.filter(s => s === 'mentat').length, 1); + const window = advance(done, g => g.turn === 2 && g.phase === 4 && clean(g)); + reject(window, f.owner, { type: 'reviveLeader', leader: f.trainer }, /All leaders must die/); + assert.equal(window.leaderSkills!.deck.filter(s => s === 'mentat').length, 1); custody(window); + } + }); + + void test(`${advanced ? 'Advanced' : 'Basic'} ${faction} original parent shipment, reveal and later Discovery entry retain native reserves and printed free-entry payment`, () => { + for (const skills of [false, true]) { + const f = discovery({ faction, advanced, skills, tech: true, strongholds: advanced }); + assert.equal(player(f.afterShipment, f.owner).reserves, player(f.beforeShipment, f.owner).reserves - 2); + assert.ok(player(f.afterShipment, f.owner).spice < player(f.beforeShipment, f.owner).spice); + assert.equal(player(f.beforeReveal, f.owner).forces['gara_kulon:8'], 2); + assert.equal(f.afterReveal.discoveries!.tokens.find(t => t.id === f.token)!.revealedTurn, 1); + assert.equal(player(f.entered, f.owner).forces['gara_kulon:8'] ?? 0, 0); + assert.equal(player(f.entered, f.owner).forces['cistern:0'], 2); + assert.equal(player(f.entered, f.owner).spice, player(f.entryWindow, f.owner).spice); + assert.equal(player(f.entered, f.owner).reserves, player(f.entryWindow, f.owner).reserves); + assert.deepEqual(f.entered.homeworlds!.custody, f.entryWindow.homeworlds!.custody); + legalPolicies(f.entryWindow, f.owner); custody(f.entered); + } + }); + + void test(`${advanced ? 'Advanced' : 'Basic'} ${faction} five paid original visitor invasions establish all-five deaths before one real own revival and private replacement training`, () => { + const f = revival({ faction, advanced, discovery: true, tech: true, strongholds: advanced }); + assert.equal(new Set(f.deaths.map(d => d.leader)).size, 5); + for (const [i, death] of f.deaths.entries()) { + assert.equal(player(death.after, f.owner).leaders.filter(l => l.dead).length, i + 1); + assert.equal(player(death.after, f.owner).leaders.find(l => l.id === death.leader)!.deaths, + player(death.before, f.owner).leaders.find(l => l.id === death.leader)!.deaths + 1); + assert.equal(death.after.lastBattleContext?.winner, f.opponent); + assert.equal(player(death.after, f.owner).tanks, player(death.before, f.owner).tanks + 1); + assert.equal(pool(death.after, f.owner, 'homeworld:guild').normal, 0); + assert.equal(player(death.after, f.opponent).hand.filter(c => c.id === death.weapon).length, 1); + assert.equal(death.after.discard.some(c => c.id === death.weapon), false); custody(death.after); + } + const trainer = player(f.revivalWindow, f.owner).leaders.find(l => l.id === f.trainer)!; + assert.equal(player(f.offeredRevival, f.owner).spice, player(f.revivalWindow, f.owner).spice - trainer.strength); + assert.equal(player(f.offeredRevival, f.owner).leaders.find(l => l.id === f.trainer)!.dead, false); + assert.equal(player(f.offeredRevival, f.owner).leaders.filter(l => l.dead).length, 4); + assert.equal(viewGame(f.offeredRevival, f.opponent).leaderSkills!.offer, null); + assert.deepEqual(viewGame(f.offeredRevival, f.owner).leaderSkills!.offer!.cards, []); + legalPolicies(f.offeredRevival, f.owner); + const event = f.offeredRevival.leaderSkills!.offers[f.owner].event; + const drawn = applyAction(structuredClone(f.offeredRevival), f.owner, { type: 'leaderSkill', event, mode: 'draw' }); + const own = viewGame(drawn, f.owner).leaderSkills!, skill = own.offer!.cards.find(s => !own.unavailableSkills?.[s]); + assert.ok(skill); assert.equal(own.offer!.cards.length, 2); + const assigned = applyAction(drawn, f.owner, { type: 'leaderSkill', event, skill, leader: f.trainer }); + assert.equal(assigned.leaderSkills!.assignments.find(a => a.owner === f.owner)!.leader, f.trainer); + assert.equal(player(assigned, f.owner).spice, player(f.offeredRevival, f.owner).spice); custody(assigned); + }); +} + +void test('Advanced native Moritani resolves Guild Suk BEFORE assassination, returns the exact trainer card, pays its printed bounty, then draws one private Mentat replacement', () => { + const f = battle({ faction: 'moritani', advanced: true, assassination: true, discovery: true, + tech: true, strongholds: true, battleTurn: 2 }); + assert.equal(f.game.strongholdCards!.owners.tueks_sietch, f.opponent); + const revealed = reveal(f.game, f.plans), pending = settle(revealed, 'assassination'); + assert.deepEqual(pending.lastBattleContext!.sukRescue!.physical!.saved, { normal: 1, elite: 0 }); + assert.equal(pending.lastBattleContext?.sukRescue?.completed, true); + assert.equal(player(pending, f.opponent).spice, player(revealed, f.opponent).spice - 3, + 'Holding Tuek never applies its Worthless income to a Homeworld battle.'); + const target = pending.leaderSkills!.assignments.find(a => a.owner === f.opponent)!.leader; + const leader = player(pending, f.opponent).leaders.find(l => l.id === target)!; + assert.equal(leader.name, 'Master Bewt'); assert.ok(player(pending, f.owner).traitors.includes(target)); + assert.notEqual(revealed.battle!.plans[f.opponent].leader, target); + const reserve = [...pending.traitorReserve!], ownTraitors = [...player(pending, f.owner).traitors]; + legalPolicies(pending, f.owner); + const decision = pending.decision; assert.ok(decision?.kind === 'moritaniAssassinate'); + let game = applyAction(structuredClone(pending), f.owner, { type: 'decision', event: decision.event, card: target }); + assert.equal(player(game, f.opponent).leaders.find(l => l.id === target)!.dead, true); + assert.equal(player(game, f.owner).spice, player(pending, f.owner).spice + leader.strength); + assert.equal(game.leaderSkills!.assignments.some(a => a.leader === target), false); + assert.equal(game.leaderSkills!.deck.filter(s => s === 'suk-graduate').length, 1); + assert.equal(player(game, f.opponent).tanks, player(pending, f.opponent).tanks); + assert.deepEqual(game.homeworlds!.custody, pending.homeworlds!.custody); + const lost = ownedTech(pending.techTokens, f.owner); assert.ok(lost.length); + game = settle(game, 'tech'); + let token = lost[0]; + if (game.decision?.kind === 'techToken') { + assert.equal(game.decision.player, f.opponent); + legalPolicies(game, f.opponent); + reject(game, f.opponent, { type: 'decision', decline: true }); + token = game.decision.choices[0]; + game = applyAction(game, f.opponent, { type: 'decision', token }); + game = settle(game); + } + assert.equal(game.techTokens![token].owner, f.opponent); + assert.equal(player(game, f.opponent).tanks, player(pending, f.opponent).tanks); + game = advance(game, g => g.phase === 8); + assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.stage, 'replaced'); + assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.replacement, reserve[0]); + assert.deepEqual(game.traitorReserve, reserve.slice(1)); + assert.deepEqual(player(game, f.owner).traitors, [...ownTraitors.filter(c => c !== target), reserve[0]]); + assert.equal('replacement' in viewGame(game, f.opponent).moritaniAssassinate!.history.at(-1)!, false); + game = advance(game, g => g.turn === 3 && g.phase === 4 && clean(g)); + reject(game, f.opponent, { type: 'reviveLeader', leader: target }, /All leaders must die/); custody(game); +}); + +void test('Basic native Moritani never acquires Advanced assassination after the same original Homeworld loss and normal Guild rescue', () => { + const f = battle({ faction: 'moritani', advanced: false, assassination: true, discovery: true, tech: true }); + const game = settle(reveal(f.game, f.plans)); + const target = game.leaderSkills!.assignments.find(a => a.owner === f.opponent)!.leader; + assert.deepEqual(game.lastBattleContext!.sukRescue!.physical!.saved, { normal: 1, elite: 0 }); + assert.equal(game.lastBattleContext?.winner, f.opponent); assert.equal(game.moritaniAssassinate, undefined); + assert.equal(player(game, f.opponent).leaders.find(l => l.id === target)!.dead, false); + assert.equal(game.leaderSkills!.assignments.find(a => a.leader === target)!.skill, 'suk-graduate'); custody(game); +}); + +void test('Homeworlds remain distinct seats rather than Ecaz Occupy territories, and Duke/Harkonnen/E3 pair exclusions remain original guards', () => { + const seats = [{ id: 'ecaz', faction: 'ecaz' as const, ally: 'guild' }, + { id: 'guild', faction: 'guild' as const, ally: 'ecaz' }, { id: 'bg', faction: 'beneGesserit' as const, ally: null }]; + assert.equal(ecazOccupancyRelation(seats, 'ecaz', 'guild', { kind: 'territory', id: 'arrakeen' }), 'ecazAlliance'); + assert.equal(ecazOccupancyRelation(seats, 'ecaz', 'guild', { kind: 'homeworld', id: 'homeworld:ecaz' }), 'different'); + const f = setup({ faction: 'ecaz', advanced: true }); + legalPolicies(f.offered, f.owner); + const offer = viewGame(f.offered, f.owner).leaderSkills!.offer!; + reject(f.offered, f.owner, { type: 'leaderSkill', event: offer.event, skill: offer.cards[0], leader: DUKE_VIDAL_ID }); + for (const roster of [['ecaz', 'moritani', 'guild'], ['ecaz', 'harkonnen', 'guild'], ['moritani', 'harkonnen', 'guild']] as FactionId[][]) { + let game = createGame('NATIVEHWGUARDS', newPlayer(roster[0], roster[0], roster[0]), true, ['ecaz']); + for (const faction of roster.slice(1)) joinGame(game, newPlayer(faction, faction, faction)); + game = applyAction(game, game.host, { type: 'homeworlds', enabled: true }); game.discoveryEnabled = true; + for (const p of game.players) game = applyAction(game, p.id, { type: 'ready' }); + const before = structuredClone(game); + assert.throws(() => initializeLeaderSkillsGameForAudit(game)); assert.deepEqual(game, before); + if (roster[0] !== 'ecaz' || roster[1] !== 'harkonnen') + assert.throws(() => initializeDiscoveryGameForAudit(game)); + assert.deepEqual(game, before); + assert.equal(game.homeworlds!.custody, null); + } + const basic = setup({ faction: 'ecaz', advanced: false, roster: ['ecaz', 'harkonnen', 'guild'], discovery: true }); + assert.equal(player(basic.afterSetup, 'harkonnen').hand.length, 2); custody(basic.afterSetup); +}); + +void test('actual Ecaz arrival at native Wallach IX never opens BG Intrusion or a territory Occupy battle', () => { + const f = setup({ faction: 'ecaz', advanced: true, roster: ['ecaz', 'guild', 'beneGesserit'], discovery: true }); + const arrival = invade(f.afterSetup, f.owner, 'homeworld:beneGesserit', 3); + assert.equal(pool(arrival.after, f.owner, 'homeworld:beneGesserit').normal, 3); + assert.deepEqual(pool(arrival.after, 'beneGesserit', 'homeworld:beneGesserit'), + pool(arrival.before, 'beneGesserit', 'homeworld:beneGesserit')); + assert.equal(arrival.after.decision, null); + assert.equal(arrival.after.pendingAmbassador ?? null, null); + assert.ok(!Object.keys(player(arrival.after, 'beneGesserit').advisors ?? {}).some(key => key.startsWith('homeworld:'))); + assert.ok(!Object.keys(player(arrival.after, f.owner).forces).some(key => key.startsWith('homeworld:'))); + custody(arrival.after); +}); diff --git a/tools/faction-games.ts b/tools/faction-games.ts index 43aac9df..40632a50 100644 --- a/tools/faction-games.ts +++ b/tools/faction-games.ts @@ -35,7 +35,7 @@ import { type Game, } from '../game/engine'; import { sampleInventory, verifySampleCustody } from './sample-custody'; -import { classicHomeworldLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile } from '../game/leader-skill-profile'; +import { homeworldLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile } from '../game/leader-skill-profile'; import { createTechTokens } from '../game/tech-tokens'; import { createStrongholdCards } from '../game/stronghold-cards'; import { privateOutputDirectory, sourceSnapshot } from './verification'; @@ -43,12 +43,18 @@ import { privateOutputDirectory, sourceSnapshot } from './verification'; const DEFAULT_SEED = 20_260_926; const DEFAULT_MAX_ACTIONS = 3_500; const DIFFICULTIES = ['Easy', 'Medium', 'Hard', 'Brutal'] as const; -const HOMEWORLD_CLASSIC_MODULE_PROFILES = [ +const HOMEWORLD_MODULE_PROFILES = [ 'homeworld-skills', 'homeworld-discovery', 'homeworld-skills-tech', 'homeworld-skills-stronghold', 'homeworld-skills-stronghold-tech', 'homeworld-discovery-tech', 'homeworld-discovery-stronghold', 'homeworld-discovery-stronghold-tech', 'homeworld-discovery-skills', 'homeworld-discovery-skills-tech', 'homeworld-discovery-skills-stronghold', 'homeworld-discovery-skills-stronghold-tech', + 'native-homeworld-skills', 'native-homeworld-skills-tech', + 'native-homeworld-skills-stronghold', 'native-homeworld-skills-stronghold-tech', + 'native-homeworld-discovery', 'native-homeworld-discovery-tech', + 'native-homeworld-discovery-stronghold', 'native-homeworld-discovery-stronghold-tech', + 'native-homeworld-discovery-skills', 'native-homeworld-discovery-skills-tech', + 'native-homeworld-discovery-skills-stronghold', 'native-homeworld-discovery-skills-stronghold-tech', ] as const; type Profile = 'base' | 'choam' | 'ecaz' | 'ecaz-treachery' | 'ecaz-occupy' | 'moritani-assassinate' | 'stronghold-factions' | 'combined' | 'combined-nexus' | 'combined-homeworld-nexus' | 'homeworld' | 'nexus' | 'homeworld-nexus' | 'choam-roster' | 'ecaz-roster' | 'ix-roster' | 'choam-nexus' | 'ecaz-nexus' | 'ix-nexus' | 'moritani-skills' | 'tleilaxu-skills' | 'ix-skills' | 'choam-skills' | 'richese-skills' | 'ecaz-skills' | 'skills-tech' | 'skills-stronghold' | 'skills-stronghold-tech' | 'factions-tech' | 'stronghold-factions-tech' | 'nexus-tech' | 'nexus-stronghold' | 'nexus-stronghold-tech' @@ -64,7 +70,7 @@ type Profile = 'base' | 'choam' | 'ecaz' | 'ecaz-treachery' | 'ecaz-occupy' | 'm | 'paired-discovery-nexus' | 'paired-discovery-nexus-tech' | 'paired-discovery-nexus-stronghold' | 'paired-discovery-nexus-stronghold-tech' | 'paired-discovery-nexus-skills' | 'paired-discovery-nexus-skills-tech' | 'paired-discovery-nexus-skills-stronghold' | 'paired-discovery-nexus-skills-stronghold-tech' | 'e3-discovery-nexus-skills' | 'e3-discovery-nexus-skills-tech' | 'e3-discovery-nexus-skills-stronghold' | 'e3-discovery-nexus-skills-stronghold-tech' - | (typeof HOMEWORLD_CLASSIC_MODULE_PROFILES)[number]; + | (typeof HOMEWORLD_MODULE_PROFILES)[number]; const discoveryStrongholdProfile = (profile: string) => profile === 'discovery-stronghold' || profile === 'discovery-stronghold-tech' || profile === 'discovery-skills-stronghold' || profile === 'discovery-skills-stronghold-tech' || profile === 'native-discovery-skills-stronghold' || @@ -87,16 +93,16 @@ const nativeDiscoveryNexusSkillsProfile = (profile: string) => profile === 'paired-discovery-nexus-skills-stronghold' || profile === 'paired-discovery-nexus-skills-stronghold-tech' || profile === 'e3-discovery-nexus-skills' || profile === 'e3-discovery-nexus-skills-tech' || profile === 'e3-discovery-nexus-skills-stronghold' || profile === 'e3-discovery-nexus-skills-stronghold-tech'; -const homeworldClassicModuleProfile = (profile: string) => - (HOMEWORLD_CLASSIC_MODULE_PROFILES as readonly string[]).includes(profile); -const homeworldClassicSkillsProfile = (profile: string) => - homeworldClassicModuleProfile(profile) && profile.includes('-skills'); -const homeworldClassicDiscoveryProfile = (profile: string) => - homeworldClassicModuleProfile(profile) && profile.includes('-discovery'); -const homeworldClassicTechProfile = (profile: string) => - homeworldClassicModuleProfile(profile) && profile.endsWith('-tech'); -const homeworldClassicStrongholdProfile = (profile: string) => - homeworldClassicModuleProfile(profile) && profile.includes('-stronghold'); +const homeworldModuleSampleProfile = (profile: string) => + (HOMEWORLD_MODULE_PROFILES as readonly string[]).includes(profile); +const homeworldSkillsSampleProfile = (profile: string) => + homeworldModuleSampleProfile(profile) && profile.includes('-skills'); +const homeworldDiscoverySampleProfile = (profile: string) => + homeworldModuleSampleProfile(profile) && profile.includes('-discovery'); +const homeworldTechSampleProfile = (profile: string) => + homeworldModuleSampleProfile(profile) && profile.endsWith('-tech'); +const homeworldStrongholdSampleProfile = (profile: string) => + homeworldModuleSampleProfile(profile) && profile.includes('-stronghold'); type Rules = 'basic' | 'advanced'; type Scenario = { @@ -172,10 +178,10 @@ const BASE_SCENARIOS: readonly Scenario[] = [2, 3, 4, 5, 6].flatMap((players) => })), ); const HOMEWORLD_CLASSIC_MODULE_SCENARIOS: readonly Scenario[] = - HOMEWORLD_CLASSIC_MODULE_PROFILES.flatMap((profile, band) => + HOMEWORLD_MODULE_PROFILES.filter(profile => !profile.startsWith('native-')).flatMap((profile, band) => BASE_SCENARIOS.map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 6400 + band * 100 + index })) - .filter(scenario => (!homeworldClassicTechProfile(profile) || scenario.roster.length >= 3) && - (!homeworldClassicStrongholdProfile(profile) || scenario.rules === 'advanced'))); + .filter(scenario => (!homeworldTechSampleProfile(profile) || scenario.roster.length >= 3) && + (!homeworldStrongholdSampleProfile(profile) || scenario.rules === 'advanced'))); const NEXUS_SKILLS_SCENARIOS: readonly Scenario[] = BASE_SCENARIOS.map((scenario, index) => ({ ...scenario, profile: 'nexus-skills', ordinal: 1410 + index, })); @@ -480,6 +486,13 @@ const NATIVE_DISCOVERY_SKILLS_SCENARIOS: readonly Scenario[] = .filter(scenario => (!profile.includes('stronghold') || scenario.rules === 'advanced') && (!profile.endsWith('tech') || scenario.roster.length >= 3)) .map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 4400 + band * 100 + index }))); +const HOMEWORLD_NATIVE_MODULE_SCENARIOS: readonly Scenario[] = + HOMEWORLD_MODULE_PROFILES.filter(profile => profile.startsWith('native-')).flatMap((profile, band) => + [...IX_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS, + ...RICHESE_SKILLS_SCENARIOS, ...ECAZ_SKILLS_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS] + .filter(scenario => (!homeworldStrongholdSampleProfile(profile) || scenario.rules === 'advanced') && + (!homeworldTechSampleProfile(profile) || scenario.roster.length >= 3)) + .map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 7600 + band * 100 + index }))); const DISCOVERY_NEXUS_SKILLS_SCENARIOS: readonly Scenario[] = (['discovery-nexus-skills', 'discovery-nexus-skills-tech', 'discovery-nexus-skills-stronghold', 'discovery-nexus-skills-stronghold-tech'] as const) .flatMap((profile, band) => BASE_SCENARIOS @@ -583,10 +596,10 @@ const SKILLS_STRONGHOLD_SCENARIOS: readonly Scenario[] = const strongholdSkillProfile = (profile: string) => profile === 'skills-stronghold' || profile === 'skills-stronghold-tech'; const discoverySkillsProfile = (profile: string) => profile === 'discovery-skills' || profile === 'discovery-skills-tech' || profile === 'discovery-skills-stronghold' || profile === 'discovery-skills-stronghold-tech'; -const skillProfile = (profile: string) => homeworldClassicSkillsProfile(profile) || nativeDiscoveryNexusSkillsProfile(profile) || discoveryNexusSkillsProfile(profile) || nativeDiscoverySkillsProfile(profile) || discoverySkillsProfile(profile) || e3NexusSkillsProfile(profile) || pairedNexusSkillsModuleProfile(profile) || nexusSkillsModuleProfile(profile) || profile === 'nexus-skills' || profile === 'paired-nexus-skills' || profile === 'moritani-skills' || profile === 'tleilaxu-skills' || profile === 'ix-skills' || profile === 'choam-skills' || profile === 'richese-skills' || profile === 'ecaz-skills' || profile === 'skills-tech' || strongholdSkillProfile(profile); +const skillProfile = (profile: string) => homeworldSkillsSampleProfile(profile) || nativeDiscoveryNexusSkillsProfile(profile) || discoveryNexusSkillsProfile(profile) || nativeDiscoverySkillsProfile(profile) || discoverySkillsProfile(profile) || e3NexusSkillsProfile(profile) || pairedNexusSkillsModuleProfile(profile) || nexusSkillsModuleProfile(profile) || profile === 'nexus-skills' || profile === 'paired-nexus-skills' || profile === 'moritani-skills' || profile === 'tleilaxu-skills' || profile === 'ix-skills' || profile === 'choam-skills' || profile === 'richese-skills' || profile === 'ecaz-skills' || profile === 'skills-tech' || strongholdSkillProfile(profile); const discoveryProfile = (profile: string) => profile === 'discovery' || profile === 'discovery-tech' || profile === 'discovery-stronghold' || profile === 'discovery-stronghold-tech' || - (homeworldClassicDiscoveryProfile(profile) && !homeworldClassicSkillsProfile(profile)); + (homeworldDiscoverySampleProfile(profile) && !homeworldSkillsSampleProfile(profile)); const assassinationScenario = (scenario: Scenario) => scenario.rules === 'advanced' && scenario.roster.includes('moritani') && (scenario.profile === 'moritani-assassinate' || discoveryProfile(scenario.profile) || nativeStrongholdProfile(scenario.profile) || nativeTechProfile(scenario.profile) || skillProfile(scenario.profile)); @@ -655,6 +668,7 @@ function usage() { + '\nDiscovery-skills selects classic/base33/all14 Skills with original Discovery7/8, Basic/Advanced2..6. Discovery-skills-tech preserves unused original Tech3..6. Original trained posture, physical free entry, fixed-three carried movement, paid nested routes, stash capacity, skill battle rescue/rewards and distinct-party Bureaucrat invoices remain. Native families, Nexus/Strongholds/HW, Banker/Mentat previews, pending contested/shared/mixed effects, public starts and save conversion stay separate.' + '\nDiscovery-nexus uses classic/base33/all12 Nexus/Discovery7+8, Basic/Advanced2..6. Discovery-nexus-tech adds original Tech3+; discovery-nexus-stronghold adds Advanced Strongholds2+; both requires3+ Advanced. Original Great Maker/worm/vote/ride, settled-alliance end-Spice closing deal, end-Mentat held-card settlement, next-turn entry and source-clear borrowed force/tariff/card effects remain. No Skills/HW/native families, mixed-module Fremen Betrayal, new pending rulings, public starts or save conversion.' + '\nHomeworld-skills, homeworld-discovery and homeworld-discovery-skills use original classic/base33 rosters in Basic/Advanced2..6, all14 offered training when Skills is selected and Discovery7/8 when Discovery is selected. Each family may add -tech (original Tech3+), -stronghold (Advanced2+) or -stronghold-tech (Advanced3+). Original setup/cards/native force sources, physical rescue, Maker/entry, phase-end industry and source-local held effects remain. No Nexus, native families, public starts, save conversion or pending-rule changes.' + + '\nNative-homeworld-skills, native-homeworld-discovery and native-homeworld-discovery-skills retain each existing E1/E2 or standalone Ecaz/Moritani roster/deck predicate and original Homeworld pools, Basic/Advanced2..6. Optional -tech requires3+; -stronghold requiresAdvanced2+; -stronghold-tech requiresAdvanced3+. Original private starts, all14 when selected, DS7/8 when selected, physical Suk/residual Cyborg exchange, native aftermath and held/industry/reward timing remain. Nexus, E3pairs/mixed families, unrelated previews, public starts, save conversion and pending rulings remain separate.' ); } @@ -685,8 +699,8 @@ function positive(value: string | undefined) { } function scenarioName(scenario: Scenario) { - if (homeworldClassicModuleProfile(scenario.profile)) - return `${scenario.profile}-classic-${scenario.roster.join('-')}-${scenario.roster.length}-${scenario.rules}`; + if (homeworldModuleSampleProfile(scenario.profile)) + return `${scenario.profile}-${scenario.expansions.join('-') || 'classic'}-${scenario.roster.join('-')}-${scenario.roster.length}-${scenario.rules}`; if (nativeDiscoverySkillsProfile(scenario.profile) || discoveryNexusSkillsProfile(scenario.profile) || pairedDiscoveryNexusSampleProfile(scenario.profile) || nativeDiscoveryNexusSkillsProfile(scenario.profile)) return `${scenario.profile}-${scenario.expansions.join('-')}-${scenario.roster.join('-')}-${scenario.roster.length}-${scenario.rules}`; @@ -779,13 +793,13 @@ function freshGame(scenario: Scenario) { player.ready = true; } if (scenario.ecazTreachery) game.ecazTreachery = true; - if (homeworldClassicTechProfile(scenario.profile) || discoveryTechProfile(scenario.profile) || scenario.profile === 'skills-tech' || scenario.profile === 'skills-stronghold-tech' || nativeTechProfile(scenario.profile) || nexusTechProfile(scenario.profile)) game.techTokens = createTechTokens(); - if (homeworldClassicStrongholdProfile(scenario.profile) || discoveryStrongholdProfile(scenario.profile) || strongholdSkillProfile(scenario.profile) || nexusStrongholdProfile(scenario.profile)) game.strongholdCards = createStrongholdCards(); - if (homeworldClassicModuleProfile(scenario.profile) || scenario.profile === 'homeworld' || scenario.profile === 'homeworld-occupation' || scenario.profile === 'homeworld-nexus' || scenario.profile === 'combined-homeworld-nexus') + if (homeworldTechSampleProfile(scenario.profile) || discoveryTechProfile(scenario.profile) || scenario.profile === 'skills-tech' || scenario.profile === 'skills-stronghold-tech' || nativeTechProfile(scenario.profile) || nexusTechProfile(scenario.profile)) game.techTokens = createTechTokens(); + if (homeworldStrongholdSampleProfile(scenario.profile) || discoveryStrongholdProfile(scenario.profile) || strongholdSkillProfile(scenario.profile) || nexusStrongholdProfile(scenario.profile)) game.strongholdCards = createStrongholdCards(); + if (homeworldModuleSampleProfile(scenario.profile) || scenario.profile === 'homeworld' || scenario.profile === 'homeworld-occupation' || scenario.profile === 'homeworld-nexus' || scenario.profile === 'combined-homeworld-nexus') game.homeworlds = { custody: null }; if (scenario.profile === 'paired-nexus-skills' || scenario.profile === 'nexus-skills' || scenario.profile === 'nexus' || nexusModuleProfile(scenario.profile) || scenario.profile === 'homeworld-nexus' || scenario.profile === 'choam-nexus' || scenario.profile === 'ecaz-nexus' || scenario.profile === 'ix-nexus' || scenario.profile === 'combined-nexus' || scenario.profile === 'combined-homeworld-nexus') game.nexusCards = { cards: null, phase: null }; - if (homeworldClassicDiscoveryProfile(scenario.profile) || nativeDiscoverySkillsProfile(scenario.profile) || discoverySkillsProfile(scenario.profile) || discoveryNexusProfile(scenario.profile)) game.discoveryEnabled = true; + if (homeworldDiscoverySampleProfile(scenario.profile) || nativeDiscoverySkillsProfile(scenario.profile) || discoverySkillsProfile(scenario.profile) || discoveryNexusProfile(scenario.profile)) game.discoveryEnabled = true; if (discoveryProfile(scenario.profile)) { game.discoveryEnabled = true; return initializeDiscoveryGameForAudit(game); @@ -846,7 +860,7 @@ function resumedGame(path: string) { throw new Error( '--resume is not an incomplete faction-games snapshot with saved AI profiles.', ); - const composedSkills = (classicHomeworldLeaderSkillsProfile(game) || classicDiscoveryLeaderSkillsProfile(game) || standaloneE3NexusLeaderSkillsProfile(game) || classicNexusLeaderSkillsProfile(game) || classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game) || strongholdLeaderSkillsProfile(game)) && !game.spiceBankerIncomePreview; + const composedSkills = (homeworldLeaderSkillsProfile(game) || classicDiscoveryLeaderSkillsProfile(game) || standaloneE3NexusLeaderSkillsProfile(game) || classicNexusLeaderSkillsProfile(game) || classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game) || strongholdLeaderSkillsProfile(game)) && !game.spiceBankerIncomePreview; const discovery = game.discoveryEnabled === true && !!game.discoveries; if ( (game.leaderSkills && !nativeExpansionLeaderSkillsProfile(game) && !composedSkills) || @@ -855,18 +869,18 @@ function resumedGame(path: string) { game.mentatQuestionPreview ) throw new Error('--resume sample scenarios exclude unsupported optional modules.'); - const scenario = [...SCENARIOS, ...COMBINED_NEXUS_SCENARIOS, ...COMBINED_HOMEWORLD_NEXUS_SCENARIOS, ...BASE_SCENARIOS, ...MODULE_SCENARIOS, ...EXPANSION_ROSTER_SCENARIOS, ...ECAZ_TREACHERY_SCENARIOS, ...ECAZ_OCCUPY_SCENARIOS, ...PAIRED_NEXUS_SCENARIOS, ...MORITANI_ASSASSINATE_SCENARIOS, ...STRONGHOLD_FACTIONS_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...IX_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS, ...RICHESE_SKILLS_SCENARIOS, ...ECAZ_SKILLS_SCENARIOS, ...SKILLS_TECH_SCENARIOS, ...SKILLS_STRONGHOLD_SCENARIOS, ...NATIVE_TECH_SCENARIOS, ...CLASSIC_NEXUS_MODULE_SCENARIOS, ...PAIRED_NEXUS_MODULE_SCENARIOS, ...NEXUS_SKILLS_SCENARIOS, ...PAIRED_NEXUS_SKILLS_SCENARIOS, ...NEXUS_SKILLS_MODULE_SCENARIOS, ...PAIRED_NEXUS_SKILLS_MODULE_SCENARIOS, ...E3_NEXUS_SKILLS_SCENARIOS, ...HOMEWORLD_OCCUPATION_SCENARIOS, ...DISCOVERY_SCENARIOS, ...DISCOVERY_SKILLS_SCENARIOS, ...DISCOVERY_NEXUS_SCENARIOS, ...DISCOVERY_STRONGHOLD_SCENARIOS, ...NATIVE_DISCOVERY_SKILLS_SCENARIOS, ...DISCOVERY_NEXUS_SKILLS_SCENARIOS, ...PAIRED_DISCOVERY_NEXUS_SCENARIOS, ...NATIVE_DISCOVERY_NEXUS_SKILLS_SCENARIOS, ...HOMEWORLD_CLASSIC_MODULE_SCENARIOS].find( + const scenario = [...SCENARIOS, ...COMBINED_NEXUS_SCENARIOS, ...COMBINED_HOMEWORLD_NEXUS_SCENARIOS, ...BASE_SCENARIOS, ...MODULE_SCENARIOS, ...EXPANSION_ROSTER_SCENARIOS, ...ECAZ_TREACHERY_SCENARIOS, ...ECAZ_OCCUPY_SCENARIOS, ...PAIRED_NEXUS_SCENARIOS, ...MORITANI_ASSASSINATE_SCENARIOS, ...STRONGHOLD_FACTIONS_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...IX_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS, ...RICHESE_SKILLS_SCENARIOS, ...ECAZ_SKILLS_SCENARIOS, ...SKILLS_TECH_SCENARIOS, ...SKILLS_STRONGHOLD_SCENARIOS, ...NATIVE_TECH_SCENARIOS, ...CLASSIC_NEXUS_MODULE_SCENARIOS, ...PAIRED_NEXUS_MODULE_SCENARIOS, ...NEXUS_SKILLS_SCENARIOS, ...PAIRED_NEXUS_SKILLS_SCENARIOS, ...NEXUS_SKILLS_MODULE_SCENARIOS, ...PAIRED_NEXUS_SKILLS_MODULE_SCENARIOS, ...E3_NEXUS_SKILLS_SCENARIOS, ...HOMEWORLD_OCCUPATION_SCENARIOS, ...DISCOVERY_SCENARIOS, ...DISCOVERY_SKILLS_SCENARIOS, ...DISCOVERY_NEXUS_SCENARIOS, ...DISCOVERY_STRONGHOLD_SCENARIOS, ...NATIVE_DISCOVERY_SKILLS_SCENARIOS, ...DISCOVERY_NEXUS_SKILLS_SCENARIOS, ...PAIRED_DISCOVERY_NEXUS_SCENARIOS, ...NATIVE_DISCOVERY_NEXUS_SKILLS_SCENARIOS, ...HOMEWORLD_CLASSIC_MODULE_SCENARIOS, ...HOMEWORLD_NATIVE_MODULE_SCENARIOS].find( (candidate) => - (homeworldClassicDiscoveryProfile(candidate.profile) || nativeDiscoverySkillsProfile(candidate.profile) || discoveryProfile(candidate.profile) || discoverySkillsProfile(candidate.profile) || discoveryNexusProfile(candidate.profile)) === discovery && - (homeworldClassicModuleProfile(candidate.profile) || candidate.profile === 'homeworld' || candidate.profile === 'homeworld-occupation' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.homeworlds && + (homeworldDiscoverySampleProfile(candidate.profile) || nativeDiscoverySkillsProfile(candidate.profile) || discoveryProfile(candidate.profile) || discoverySkillsProfile(candidate.profile) || discoveryNexusProfile(candidate.profile)) === discovery && + (homeworldModuleSampleProfile(candidate.profile) || candidate.profile === 'homeworld' || candidate.profile === 'homeworld-occupation' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.homeworlds && (candidate.profile === 'paired-nexus-skills' || candidate.profile === 'nexus-skills' || candidate.profile === 'nexus' || nexusModuleProfile(candidate.profile) || candidate.profile === 'homeworld-nexus' || candidate.profile === 'choam-nexus' || candidate.profile === 'ecaz-nexus' || candidate.profile === 'ix-nexus' || candidate.profile === 'combined-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.nexusCards && (candidate.profile === 'homeworld-occupation') === !!game.homeworldOccupationPreview && skillProfile(candidate.profile) === !!game.leaderSkills && - (homeworldClassicTechProfile(candidate.profile) || discoveryTechProfile(candidate.profile) || candidate.profile === 'skills-tech' || candidate.profile === 'skills-stronghold-tech' || nativeTechProfile(candidate.profile) || nexusTechProfile(candidate.profile)) === !!game.techTokens && - (homeworldClassicStrongholdProfile(candidate.profile) || discoveryStrongholdProfile(candidate.profile) || nativeStrongholdProfile(candidate.profile) || strongholdSkillProfile(candidate.profile) || nexusStrongholdProfile(candidate.profile)) === !!game.strongholdCards && + (homeworldTechSampleProfile(candidate.profile) || discoveryTechProfile(candidate.profile) || candidate.profile === 'skills-tech' || candidate.profile === 'skills-stronghold-tech' || nativeTechProfile(candidate.profile) || nexusTechProfile(candidate.profile)) === !!game.techTokens && + (homeworldStrongholdSampleProfile(candidate.profile) || discoveryStrongholdProfile(candidate.profile) || nativeStrongholdProfile(candidate.profile) || strongholdSkillProfile(candidate.profile) || nexusStrongholdProfile(candidate.profile)) === !!game.strongholdCards && (candidate.profile === 'ecaz-treachery' || candidate.ecazTreachery === true) === !!game.ecazTreachery && assassinationScenario(candidate) === !!game.moritaniAssassinatePreview && - (candidate.profile === 'ecaz-occupy' || ((nativeDiscoveryNexusSkillsProfile(candidate.profile) || nativeDiscoverySkillsProfile(candidate.profile) || nativeTechProfile(candidate.profile) || discoveryProfile(candidate.profile)) && candidate.roster.includes('ecaz'))) === !!game.ecazOccupyPreview && + (candidate.profile === 'ecaz-occupy' || ((homeworldModuleSampleProfile(candidate.profile) || nativeDiscoveryNexusSkillsProfile(candidate.profile) || nativeDiscoverySkillsProfile(candidate.profile) || nativeTechProfile(candidate.profile) || discoveryProfile(candidate.profile)) && candidate.roster.includes('ecaz'))) === !!game.ecazOccupyPreview && assassinationScenario(candidate) === !!game.moritaniAssassinate && candidate.rules === (game.advanced ? 'advanced' : 'basic') && JSON.stringify(candidate.expansions) === @@ -1080,7 +1094,8 @@ async function main() { if ((discoveryTechProfile(profile) || (discoveryNexusProfile(profile) && nexusTechProfile(profile))) && players === 2) throw new Error('Discovery with Tech Tokens requires three through six players.'); const resume = values.resume ? resumedGame(values.resume) : null; - const requestedSamples = homeworldClassicModuleProfile(profile) ? HOMEWORLD_CLASSIC_MODULE_SCENARIOS + const requestedSamples = homeworldModuleSampleProfile(profile) + ? (profile.startsWith('native-') ? HOMEWORLD_NATIVE_MODULE_SCENARIOS : HOMEWORLD_CLASSIC_MODULE_SCENARIOS) : nativeDiscoveryNexusSkillsProfile(profile) ? NATIVE_DISCOVERY_NEXUS_SKILLS_SCENARIOS : discoveryNexusSkillsProfile(profile) ? DISCOVERY_NEXUS_SKILLS_SCENARIOS : pairedDiscoveryNexusSampleProfile(profile) ? PAIRED_DISCOVERY_NEXUS_SCENARIOS diff --git a/tools/start-prototype.ts b/tools/start-prototype.ts index 24e4ea1e..5e130735 100644 --- a/tools/start-prototype.ts +++ b/tools/start-prototype.ts @@ -41,10 +41,10 @@ async function main() { '\nHomeworld-occupation opts a fresh ready 2–6-seat Basic/Advanced lobby with original selected faction decks into occupied bank Collection/immediate ally sharing, Kaitain/Junction/Richese percentage receipts, completed total Southern Hemisphere Collection, Caladan shared inspection, displaced Ix pool control, Richese card-only selection with native seller terms, Giedi receiver choice and occupied native defenses. Advanced follows the authorized supplied rulebook p22: sole foreign qualification, retained until its last force leaves; later contests/native repopulation do not expire it, and a new sole arrival after departure has a new source epoch. Basic retains unresolved lifecycle guards. Native income/peek cancellation remains distinct from printed occupied effects. Other borrowed/exceptional powers, overlays, public starts and old-game conversion remain separate.', '\nWhen native CHOAM is seated, this fresh entry additionally marks original occupied Tupile hand slots and owner-selected normal-limit cleanup. The occupier/reciprocal ally gain one validated slot through existing capacity consumers; Advanced departure and ally removal queue the original lost-holder cleanup. Private selected-ID discards preserve current phase/opening and remaining movement, with no new card or fee. Known occupation suppresses new native low-intelligence queries; proved Advanced expiry restores eligibility without resetting historical answers/usage. Basic unknown cases retain source guards. Unmarked played profiles are not upgraded.', '\nWhen native Ecaz is seated, this original fresh entry separately marks its printed high Homeworld alliance victory: current native seven-plus, one actual joint stronghold, two distinct other native factions with proved current occupied holdings. It adds no ordinary stronghold/Tech points, pays no reward and changes no Duke authority. Public progress remains prospective until original Mentat; qualifying members join original winners before BG prediction/final fallback. Unmarked played/public profiles remain unchanged.', - '\nDiscovery opts fresh classic, selected E1/E2 native families or standalone Ecaz OR Moritani with classics into original Discovery setup in Basic/Advanced2–6, optionally retaining unused original Tech3–6 and Advanced Strongholds2–6. Classic profiles additionally retain original Homeworlds. Original decks/cache, HMS/Face Dancers, typed entry, revealed-only nested No-Fields, stash limits, end-Mentat held claims and native outcomes remain. Advanced Moritani excludes Harkonnen. Skills/Nexus, native Homeworld combinations, paired/mixed E3, contested/shared-payer rulings, public starts and played-game conversion stay separate.', - '\nLeader-skills --discoveries explicitly composes fresh supported classic or native original family decks/all14 Skills with Discovery7/8 at2–6 seats Basic/Advanced under each existing roster predicate. Unused original Tech requires3–6; original Strongholds requires Advanced2–6. Classic profiles additionally retain original Homeworlds without Nexus. --nexus-cards retains all12 with classic factions, both original paired E1/E2 natives, or standalone Ecaz OR Moritani with classics. Actual offered training/starting hands, Maker votes/typed rides, both Advanced piles and settled-alliance end-Spice closing deals remain. Signed native pairs, trained rescue/cleanup/Tech/Face Dance and mandatory owner-labelled Ecaz or original Moritani consumers keep their sources. Mixed/E3 pairs, native Homeworld combinations/independent variants, Banker/Mentat previews, shared Duke/borrowed Smuggler/pair companion/native return and other pending rulings, public starts and save conversion remain guarded.', + '\nDiscovery opts fresh classic, selected E1/E2 native families or standalone Ecaz OR Moritani with classics into original Discovery setup in Basic/Advanced2–6, optionally retaining original Homeworlds, unused Tech3–6 and Advanced Strongholds2–6. Original decks/cache, HMS/Face Dancers, typed entry, revealed-only nested No-Fields, stash limits, end-Mentat claims and native outcomes remain. Advanced Moritani excludes Harkonnen. Skills/Nexus, paired/mixed E3, contested/shared-payer rulings, public starts and played-game conversion stay separate.', + '\nLeader-skills --discoveries composes fresh supported classic or native original family decks/all14 Skills with Discovery7/8 at2–6 seats Basic/Advanced under each existing roster predicate. Original Homeworlds may be retained without Nexus; Tech requires3–6 and Strongholds Advanced2–6. --nexus-cards separately retains all12 with classic factions, paired E1/E2 natives or standalone Ecaz OR Moritani with classics, without Homeworlds. Original private card choices/training/hands, Maker/votes/rides, physical residual Cyborg exchange, native aftermath and cleanup/Tech/Face Dance retain their own timing. E3pairs/mixed families, independent variants, Banker/Mentat previews, shared Duke/borrowed Smuggler/pair companion/native returns and pending rulings remain separate.', '\nNexus --discoveries explicitly composes fresh classic/base33 OR both native Ixians+Tleilaxu/Ix47 OR CHOAM+Richese/CHOAM35+cache with all12 Nexus and original Discovery7/8 at2–6 seats Basic/Advanced. Unused Tech requires3+; optional Strongholds require Advanced; both3+. Actual Great Maker losses/votes/typed reserve rides, both Advanced piles, settled-alliance end-Spice closing deals and original held claims remain. Signed native two-marker nested shipments retain one printed invoice, current revealed destination and original reserve materialization. Native Skills, HW, E3/mixed families, mixed-module Fremen Betrayal, pending rulings, public activation and save conversion stay guarded.', - '\nA fresh classic/base33 lobby with Homeworlds already enabled supports leader-skills, discovery or leader-skills --discoveries, Basic/Advanced2–6. Preserve original unused Tech at3+ and Advanced Strongholds at2+; both require Advanced3+. Original all14 offered training, selected Discovery7/8 and native/split Imperial pools remain. Actual Homeworld/nested battles, source-qualified rescue, Maker votes/typed reserve rides, parent-only entry, phase-end industry and source-local held effects use original consumers and controls. No Nexus/native families, public starts or played-save conversion.', + '\nFresh supported classic or native E1/E2/standalone E3 lobbies with original Homeworlds support leader-skills, discovery or leader-skills --discoveries, Basic/Advanced2–6. Preserve unused Tech3+ and Advanced Strongholds2+; both requireAdvanced3+. Native roster/deck/assassination/Duke boundaries remain. Original all14/DS7+8 when selected, private native starts, split Imperial and typed native/visitor/nested pools, phase-end industry, held effects and winner rewards use original consumers. Nexus, unsupported E3pair/mixed rosters, unrelated previews, public starts and save conversion remain separate.', ); return; }