From 09eb857583ad0bb1651026d8df528d248ea7f965 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:10:32 +1100 Subject: [PATCH] Initialize occupation benefits in original World module setups --- docs/CURRENT_STATUS.md | 32 +++ docs/DISCOVERY_PROTOTYPE.md | 5 + docs/HOMEWORLD_RULES.md | 54 ++++ docs/IMPLEMENTATION_STATUS.md | 31 +++ docs/NEXUS_CARD_RULES.md | 5 + docs/RULE_DECISIONS.md | 1 + game/engine.ts | 14 +- game/homeworld-module-profile.ts | 64 ++++- game/reference.ts | 3 +- tests/fixture-mixed-phase-modules.ts | 23 +- tests/fixture-world-module-occupation.ts | 231 ++++++++++++++++++ ...world-classic-nexus-skills-runtime.test.ts | 6 +- ...meworld-occupation-modules-profile.test.ts | 154 ++++++++++++ tests/homeworld-paired-nexus-runtime.test.ts | 22 +- ...homeworld-standalone-nexus-runtime.test.ts | 5 + .../world-module-occupation-controls.test.tsx | 55 +++++ tests/world-module-occupation-income.test.ts | 118 +++++++++ tests/world-module-occupation-runtime.test.ts | 105 ++++++++ 18 files changed, 916 insertions(+), 12 deletions(-) create mode 100644 tests/fixture-world-module-occupation.ts create mode 100644 tests/homeworld-occupation-modules-profile.test.ts create mode 100644 tests/world-module-occupation-controls.test.tsx create mode 100644 tests/world-module-occupation-income.test.ts create mode 100644 tests/world-module-occupation-runtime.test.ts diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index e311b0b2..6dbc6fec 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,38 @@ open mode gates. ## Current checkpoint and work +**Homeworld occupation / original Nexus and Discovery — 26 October. Prototyped / Partial.** +Shared fresh World setup now initializes mandatory occupation benefits in its +existing Nexus and/or Discovery envelopes, optionally Skills and phase modules. +Original start/role limits remain; existing occupation resources/history cannot +be overwritten. Live predicates retain original reaction/native flags and +active printed Sandtrout where those module profiles permit it. Legacy Semuta +keeps its separate scope; played games gain no stamp. + +Disjoint profile/control/native lanes and root integration pass **19 files, +105/105**, types/lint/build and **three native runtime paths**,19 GameTable SSR +renders. Original A/Emperor/Fremen base33 starts use36/40/46 actions for Basic +Nexus/Tech, Basic Discovery/Skills and Advanced combined modules. Another7/10/10 +actions retain real Caladan qualification/Collection2, optional deferred Banker1 +at Mentat, distinct phase rewards and earned Stronghold cards. Four minimal +policies each submit a legal next action in every source. + +Discovery paths use original Hagga/Cistern lotteries, actual ordinary city-range3 +movement, Collection inspection/reveal and next-Storm free entry. Seventeen +additional Basic Nexus actions select an unopened ordinary worm, form a real +A/F alliance and give unallied Emperor its actual closing draw. Original33, +optional14/12, Discovery7 cards/8 tokens and all physical counters remain. +Feedback filters an empty Salusa source only at the cross-World fixture adapter, +updates two older native Revival actors, and requires the actual new Junction2 +occupation receipt in an older CHOAM battle balance; printed economics remain. + +Frozen report/manifest: +`/home/quintine/.local/share/dune/checkpoints/20261026-world-module-occupation-proof-v2/`; +journeys/HTML: adjacent `20261026-world-module-occupation-runtime/`. +No saved-room/D1/server, reset/restart, deployment or assurance campaign. +SSR/public actions leave browser/mobile/deployed, wider combinations and +complete-mode acceptance open. [Source and contract](HOMEWORLD_RULES.md#original-world-nexus-and-discovery-occupation--26-october-2026). + **Leader Skills / mandatory World occupation — 26 October. Prototyped / Partial.** Existing clean registered World/all14 starts now initialize original occupation benefits beside normal Banker, with optional independent cards and phase modules. diff --git a/docs/DISCOVERY_PROTOTYPE.md b/docs/DISCOVERY_PROTOTYPE.md index ef3959b7..55e14a8d 100644 --- a/docs/DISCOVERY_PROTOTYPE.md +++ b/docs/DISCOVERY_PROTOTYPE.md @@ -162,6 +162,11 @@ Two original ruling captures and all broader release/assurance gates remain. ## Native Homeworld Nexus composition — 6 October 2026 +The [26 October fresh occupation follow-up](HOMEWORLD_RULES.md#original-world-nexus-and-discovery-occupation--26-october-2026) +supplies mandatory World occupation state in existing admitted Discovery +setups, with or without original Nexus/Skills. Native setup, nested entry, +physical rewards and each original module envelope remain authoritative. + Original paired Ixians+Tleilaxu OR CHOAM+Richese Nexus/Discovery entries, with or without Skills, now retain Homeworlds and optional Tech3+/Advanced Strongholds2+. Standalone Ecaz OR Moritani/classic Homeworld/Nexus composes diff --git a/docs/HOMEWORLD_RULES.md b/docs/HOMEWORLD_RULES.md index 974cae24..a7cce56a 100644 --- a/docs/HOMEWORLD_RULES.md +++ b/docs/HOMEWORLD_RULES.md @@ -2,6 +2,60 @@ Source audit, 9 September 2026. No runtime activation. The primary module source is the [GF9 Ecaz & Moritani rulebook](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf), especially printed pp.4, 9–10 and the Homeworld FAQ on pp.15–16. The local publisher-text transcription is `/tmp/dune-rules/ecaz-audit.txt`; fresh official indexed pp.9, 10 and 15 were checked against it. Direct PDF retrieval currently returns HTTP 403. Physical card faces are separately audited in [Homeworld component audit](HOMEWORLD_COMPONENT_AUDIT.md); a rulebook statement and a card-face statement are not interchangeable evidence. +## Original World Nexus and Discovery occupation — 26 October 2026 + +Every original admitted fresh World setup now initializes occupation benefit +state in the shared setup path, including its existing Nexus and/or Discovery +envelopes, with optional Skills and original phase modules. Entry predicates +remain authoritative for selected families, roles, physical components and +module prerequisites. This supplies omitted printed World behavior without +opening any new start permission. Legacy Semuta retains its separate scope. + +Native locations, original qualification history, occupied Collection and +percentage ledgers, native Tupile and Ecaz World victory initialize together. +Fresh setup rejects existing occupation resources or history rather than +overwriting them; played games gain no stamp. Original nested Discovery entry, +Nexus custody/reactions and Skill training remain in their existing handlers. +The live occupied-income context uses the same original module predicates, +including their actual initialized reaction/native flags. + +Authority remains the audited World/occupied components below and the supplied +Advanced physical21/printed19 variant independence. Occupied bank Collection, +ordinary territory Collection, other-payer Banker at Mentat, token payout and +earned Stronghold/Nexus cards keep their original events and arithmetic. +There is no new numeric ruling or retrospective income grant. + +Frozen root integration passes **19 files,105/105**, types/lint/build and three +native runtime paths with19 GameTable SSR renders. Public original A/E/F base33 +starts use36/40/46 actions for Basic Nexus/Tech, Basic Discovery/Skills and +Advanced Nexus/Discovery/Skills/Tech/Strongholds;7/10/10 continuation actions +retain actual Bank10 evacuation, Bank1 entry, sole Caladan qualification, +Collection2 and optional Banker1 deferred until Mentat. Native phase-token and +ordinary Collection rewards, plus earned first end-Mentat cards, stay separate. +Four minimal policies each submit a legal next action per source. + +Discovery selects only the original unopened Hagga card and Cistern supply +lottery. Actual city-range3 movement earns Collection inspection/reveal and +next-Storm free entry. A further17 Basic Nexus actions select an unopened +ordinary worm, form an actual A/F alliance and let unallied Emperor draw its +physical Nexus at the real close. Original33 cards, optional14 Skills/12 Nexus, +seven Discovery Spice cards and eight tokens remain; no played card, force, +wallet, skill, qualification, phase, owner or receipt is installed. + +Feedback keeps the Emperor native-withdrawal quote but omits its empty Salusa +group from a cross-World shipment, matching the existing human selector. Two +old tests await actual Advanced Revival actors. An older CHOAM World battle now +requires its newly earned sole Junction qualification and settled printed2 +receipt before adding that award to the original support/Collection balance. +This corrects an omitted benefit, not support pricing or held-card locality. + +Report/source manifest: +`/home/quintine/.local/share/dune/checkpoints/20261026-world-module-occupation-proof-v2/`; +journeys/HTML: adjacent `20261026-world-module-occupation-runtime/`. +No saved-room/D1/server, reset/restart, deployment or separate assurance campaign. +SSR/public actions leave broader combinations, browser/mobile/deployed and +complete-mode acceptance open. + ## Registered World Skills occupation — 26 October 2026 Fresh World/all14 Skills starts now initialize the existing occupation benefit diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 188decf0..54399b12 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,36 @@ # Dune implementation status +## 26 October 2026 — Original World Nexus and Discovery occupation + +**Prototyped / Partial.** Shared fresh World setup +initializes mandatory original occupation state in existing Nexus and/or +Discovery envelopes, optionally Skills and phase modules. Original entry and +native role predicates remain; played games and existing occupation resources +are not replaced. Live original reaction/Discovery/Sandtrout contexts retain +their actual predicates; legacy Semuta keeps its separate scope. + +Disjoint profile/control/native lanes plus root integration pass **19 files, +105/105**, types/lint/build and **three native paths**,19 GameTable SSR renders. +Public A/E/F base33 starts use36/40/46 actions plus7/10/10 continuations: Basic +Nexus/Tech, Basic Discovery/Skills and Advanced combined original modules. +Actual Caladan Bank10 evacuation/Bank1 entry earns Collection2; optional Banker1 +waits for Mentat beside original phase income and earned Strongholds. Four +minimal policies each submit a legal next action per source. Original Hagga/ +Cistern supply, city-range3 movement, inspection/reveal and next-Storm entry +remain actual actions. Seventeen further Basic Nexus actions form a real A/F +alliance after an unopened ordinary worm and give unallied Emperor its closing +physical draw. Original33/optional14/12 and Discovery7/8 inventories remain. + +Feedback adapts empty Salusa selections, two older Advanced Revival actors and +an old CHOAM balance: its actual World victory now must supply the sole Junction +qualification and settled printed2 receipt. Original support economics, +typed losses, native custody and source-local held cards remain. Frozen proof: +`/home/quintine/.local/share/dune/checkpoints/20261026-world-module-occupation-proof-v2/`; +journeys/HTML: adjacent `20261026-world-module-occupation-runtime/`. +No saved-room/D1/server/reset/restart/deployment or assurance campaign. +Wider combinations, browser/mobile/deployed and complete-mode acceptance remain. +[Source and scope](HOMEWORLD_RULES.md#original-world-nexus-and-discovery-occupation--26-october-2026). + ## 26 October 2026 — Registered World Skills occupation benefits **Prototyped / Partial.** Existing clean registered diff --git a/docs/NEXUS_CARD_RULES.md b/docs/NEXUS_CARD_RULES.md index 2bff1a8d..6b18e0d6 100644 --- a/docs/NEXUS_CARD_RULES.md +++ b/docs/NEXUS_CARD_RULES.md @@ -348,6 +348,11 @@ combination/assurance acceptance remain deferred. ## Original World Nexus core — 18 October 2026 +The [26 October fresh occupation follow-up](HOMEWORLD_RULES.md#original-world-nexus-and-discovery-occupation--26-october-2026) +initializes original World occupation benefits for already admitted Nexus +setups through shared fresh setup. Original entry, physical card and reaction +profiles below retain their own limits; this does not widen their admission. + **Prototyped / Partial.** Fresh original base33 classic Homeworld+Nexus now initializes the actual Guild/Harkonnen/Ixian reaction states through the public core capability, optionally all14 Skills and original Tech3+/Advanced diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index ea9a32c6..e489e987 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1483,6 +1483,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP | Classic Nexus core / original phase modules | Fresh classic base33 or selected36/Nexus12, optionally14, composes original Tech3–6 Basic/Advanced and Strongholds2–6 Advanced through the public core capability. Real first-Storm tokens, owner-count pending income, actual end-Mentat held cards and original matching payout remain. Fixed paid force groups do not become free under Recruits; free requests accrue once, pending income cannot fund earlier costs, and fees/Harass are not Revival/shipment. Earned local bank support does not fund reserve fees; physical effective Carthag defense, native counters/cleanup/winner token/private replacement keep their original order. No new numerical or universal policy, played reflag or wider family/World/Discovery opening. | [Source, controls and owner-frozen439/439](NEXUS_CARD_RULES.md#classic-nexus-original-phase-modules--18-october-2026) | | Original World Nexus core | Fresh base33 classic Homeworld/Nexus composes optional all14 and original Tech3+/Advanced Strongholds2+ through the public core capability; independent36 World/Nexus stays a separate pending own-World fee/conversion gate with explicit public messaging. Pending parents bind only present World custody; signed Cunning World frames and genuine discounted Smuggler invoices supply the funded Guild fee while standalone envelopes keep narrower sources. Typed visitor/native pools, printed tariffs, native permission/rate order, singleton spending, temporary-role support and ordinary-only casualties, native Traitor callers and original cleanup/replacement remain. No new numeric policy, played reflag or wider envelope opening. | [Owner-frozen448/448, scenes and limits](NEXUS_CARD_RULES.md#original-world-nexus-core--18-october-2026) | | Fresh native Ecaz/Moritani defaults | Fresh actual Eca/Mori roles reuse registered selected-deck all14 capability, including mixed families and original World/phase/cards without Nexus. Printed Eca participation and Advanced Mori assassination initialize only for actual seats; Basic gains no Advanced power and unused decks create no faction seat. Bare rosters and original Nexus/Discovery/Semuta retain their envelopes; Advanced role/shared Duke guards stay. No played-save forfeiture/opportunity history is fabricated. | [Current registered source envelope](LEADER_SKILLS_RUNTIME.md#registered-native-e3-selected-decks--18-october-2026), [Mori authority](MORITANI_ASSASSINATE_LEADERS.md#connected-scope-and-private-information) | +| Original World Nexus/Discovery occupation | Shared fresh World setup supplies mandatory original occupation state in already admitted Nexus and/or Discovery envelopes, optionally Skills and original phase modules. Original entry predicates, native flags and phase limits remain authoritative; existing occupation resources/history cannot be overwritten. Printed qualification, Collection, percentage/Tupile/Ecaz benefits compose with original nested entry, Nexus custody and deferred Banker without a new numeric policy or played-game stamp. | [Source and current boundary](HOMEWORLD_RULES.md#original-world-nexus-and-discovery-occupation--26-october-2026) | | Registered World Skills / occupation benefits | Existing registered clean World/all14 admission now initializes original occupation benefits alongside ordinary Skill/Banker state, with optional independent cards and original phase modules. No new roster/role permission or numeric policy. Real other-payer Bank payment earns deferred Banker; occupied Collection, ordinary Collection, token payout and actual Mentat settlement stay distinct. Preserve original native flags, physical offers/leaders and private choices; Nexus/Discovery/Semuta and played-game contexts remain separate. | [Current source and contract](HOMEWORLD_RULES.md#registered-world-skills-occupation--26-october-2026) | | Clean Homeworlds / original phase modules | Audited printed World/occupied faces and supplied Advanced variant independence compose registered classic/no-deck and selected-family paired/mixed/all-three rosters with optional independent cards, Tech3–6 and Advanced Strongholds2–6. Fresh setup includes original occupation bank/percentage ledgers, native CHOAM leases and Ecaz World victory; module locations alone are insufficient. Native qualification/income/auction/Duke/tariff and existing Advanced cutovers remain. No new numeric policy, played-game stamp/retrofit or Skills/Nexus/Discovery/Semuta expansion. | [Current source and scope](HOMEWORLD_RULES.md#clean-homeworlds-and-phase-modules--26-october-2026) | | Selected independent cards / optional phase modules | Printed E3 variant independence and supplied Advanced physical21/printed19 compose clean registered selected-deck paired/mixed/all-three families and classic-only unused decks. Basic mixed Ecaz admits bare cards without incidental Tech; optional original Tech3–6 Basic/Advanced and Strongholds2–6 Advanced retain real setup/owners/claims. Existing Recruits current rates, separate Tleilaxu bank income, delayed Axlotl, normal-first reserve fee, owner-only coalition Harass and selected-winner Tech remain. Advanced native Assassinate uses the existing captured-leader contract; no new numerical ruling, bare mixed World/Skills/Nexus/Discovery/Semuta permission or played-game conversion. | [Current source and scope](ECAZ_TREACHERY_RULES.md#selected-independent-cards-and-phase-modules--26-october-2026) | diff --git a/game/engine.ts b/game/engine.ts index 7e53700c..6059cbd6 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -17,7 +17,7 @@ import { MovementClaimError, liveMovementPromises, movementEventAllowed, movemen import { findMovementCompletion, type MovementCompletion } from './movement-promise-search'; import { classicNexusModulesProfile, mixedE1E2NexusProfile, mixedE3NexusProfile, pairedE3NexusProfile, pairedNexusModulesProfile, singleE1E2NexusProfile, standaloneE3NexusProfile } from './nexus-module-profile'; import { independentDiscoveryProfile, independentNexusProfile, nexusCoreProfile, nativeFactionTechProfile, selectedFactionStrongholdProfile, selectedIndependentCardsProfile } from './faction-module-profile'; -import { homeworldOccupationLeaderSkillsProfile, homeworldPhaseModulesProfile } from './homeworld-module-profile'; +import { homeworldOccupationLeaderSkillsProfile, homeworldOccupationModulesProfile, homeworldPhaseModulesProfile } from './homeworld-module-profile'; import { classicDiscoveryLeaderSkillsProfile, classicDiscoveryNexusProfile, homeworldDiscoveryProfile, discoveryModeSupported, discoveryModuleProfile, mixedE1E2DiscoveryNexusProfile, mixedE3DiscoveryNexusProfile, pairedDiscoveryNexusProfile, pairedE3DiscoveryNexusProfile, singleE1E2DiscoveryNexusProfile, standaloneE3DiscoveryNexusProfile } from './discovery-module-profile'; import { advancedMoritaniLeaderSkillsProfile, selectedDeckLeaderSkillsProfile, homeworldLeaderSkillsProfile, independentTreacheryLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, guildBetrayalNexusLeaderSkillsProfile, mixedE1E2NexusLeaderSkillsProfile, mixedE3NexusLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeEcazLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, pairedE3LeaderSkillsProfile, pairedE3NexusLeaderSkillsProfile, pairedNexusLeaderSkillsProfile, singleE1E2NexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile } from './leader-skill-profile'; import { bribeTimingBlock, maximumBribe, type BribeOptions } from './bribe-options'; @@ -12098,7 +12098,6 @@ export function initializeLeaderSkillsGameForAudit(state: Game): Game { mixedE3 || standaloneE3NexusLeaderSkillsProfile(g) || pairedE3 || guildBetrayalNexusLeaderSkillsProfile(g), false, independentDiscoveryProfile(g) || classicDiscoveryLeaderSkillsProfile(g) || nativeDiscoveryLeaderSkillsProfile(g) || (g.discoveryEnabled === true && classicNexus), true, g.expansions.length === 1 && g.expansions[0] === 'choam', nativeExpansionLeaderSkillsProfile(g), independent); - if (homeworldOccupationLeaderSkillsProfile(initialized)) initializeHomeworldOccupationState(initialized); if (advancedMoritaniLeaderSkillsProfile(initialized) || ((independentDiscoveryProfile(initialized) || independentNexusProfile(initialized) || mixedE3 || pairedE3) && initialized.advanced && byFaction(initialized, 'moritani'))) initializeMoritaniAssassinateState(initialized); if ((independentDiscoveryProfile(initialized) || independentNexusProfile(initialized) || nativeEcazLeaderSkillsProfile(initialized) || mixedE3 || homeworlds || nativeDiscoveryLeaderSkillsProfile(initialized) || pairedE3) && byFaction(initialized, 'ecaz')) initialized.ecazOccupyPreview = true; @@ -12177,7 +12176,6 @@ export function initializeEcazTreacheryGameForAudit(state: Game): Game { requireFreshFactionInventory(g); } const initialized = initializeSetupGameForAudit(g, homeworlds, independentNexusProfile(g), false, discovery, false, false, true, true); - if (homeworlds && homeworldPhaseModulesProfile(initialized)) initializeHomeworldOccupationState(initialized); if (byFaction(initialized, 'ecaz')) initialized.ecazOccupyPreview = true; if (moritaniAssassinateModeSupported(initialized)) initializeMoritaniAssassinateState(initialized); return initialized; @@ -12191,6 +12189,13 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f homeworldShipmentIntegrity(state); const g = structuredClone(state); requireFreshSetup(g, homeworlds || nexus || ix || factions); + if (homeworlds) requireRule(g.homeworlds?.custody === null && + [g.homeworldOccupationPreview, g.homeworldOccupationHistory, + g.pendingHomeworldOccupiedIncome, g.homeworldOccupiedIncomeHistory, + g.homeworldOccupiedPercentageLedger, g.pendingHomeworldOccupiedPercentage, + g.pendingHomeworldOccupiedBonus, g.homeworldTupilePreview, g.homeworldTupileState, + g.pendingHomeworldTupileCleanup, g.homeworldEcazVictoryPreview].every(value => value === undefined), + 'Fresh Homeworld setup cannot replace existing occupation resources or history.'); const homeworldSkills = leaderSkills && homeworldLeaderSkillsProfile(g); const skillStronghold = leaderSkills && strongholdLeaderSkillsProfile(g); const skillTech = leaderSkills && (classicTechLeaderSkillsProfile(g) || nativeTechLeaderSkillsProfile(g) || skillStronghold || homeworldSkills || classicDiscoveryLeaderSkillsProfile(g)); @@ -12292,6 +12297,7 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f g.semutaPreview = true; } initializeSetup(g, strongholdFactions || !!g.strongholdCards); + if (homeworlds) initializeHomeworldOccupationState(g); if (nativeTech && byFaction(g, 'ecaz')) g.ecazOccupyPreview = true; return normalizeAutomaticGame(g); } @@ -24223,7 +24229,7 @@ function homeworldOccupiedIncomeIntegrity(g: Game) { return; } requireRule(g.homeworldOccupationPreview === true && g.homeworlds && - (homeworldPhaseModulesProfile(g) || homeworldOccupationLeaderSkillsProfile(g) || (!g.nexusCards && + (homeworldPhaseModulesProfile(g) || homeworldOccupationLeaderSkillsProfile(g) || homeworldOccupationModulesProfile(g) || (!g.nexusCards && (!g.leaderSkills || !g.expansions.length && homeworldLeaderSkillsProfile(g)) && !g.techTokens && !g.strongholdCards && !g.discoveryEnabled)) && Array.isArray(history) && pending !== undefined, diff --git a/game/homeworld-module-profile.ts b/game/homeworld-module-profile.ts index 1aca2d9e..5b988adf 100644 --- a/game/homeworld-module-profile.ts +++ b/game/homeworld-module-profile.ts @@ -1,6 +1,26 @@ import { FACTIONS } from './catalog'; -import type { FactionModuleProfile } from './faction-module-profile'; -import { homeworldLeaderSkillsProfile } from './leader-skill-profile'; +import { independentDiscoveryProfile, nexusCoreProfile, type FactionModuleProfile } from './faction-module-profile'; +import { + classicNexusLeaderSkillsProfile, + homeworldLeaderSkillsProfile, + nativeDiscoveryLeaderSkillsProfile, + pairedNexusLeaderSkillsProfile, +} from './leader-skill-profile'; +import { + classicDiscoveryLeaderSkillsProfile, + classicDiscoveryNexusProfile, + homeworldDiscoveryProfile, + pairedDiscoveryNexusProfile, +} from './discovery-module-profile'; +import { + classicNexusModulesProfile, + mixedE1E2NexusProfile, + mixedE3NexusProfile, + pairedE3NexusProfile, + pairedNexusModulesProfile, + singleE1E2NexusProfile, + standaloneE3NexusProfile, +} from './nexus-module-profile'; export type HomeworldPhaseModulesProfile = FactionModuleProfile & { homeworldOccupationPreview?: unknown; @@ -72,3 +92,43 @@ export function homeworldOccupationLeaderSkillsProfile(game: HomeworldPhaseModul (!game.homeworldEcazVictoryPreview || game.homeworldOccupationPreview === true && ecaz) && homeworldLeaderSkillsProfile(game); } + +/** Occupation benefits for already-admitted original World/Nexus or Discovery + * setups. Pass the actual configuration to its unchanged admission predicates: + * live effect stamps and Discovery frames are not replacement module choices. */ +export function homeworldOccupationModulesProfile(game: HomeworldPhaseModulesProfile): boolean { + if (!game || typeof game !== 'object' || !game.homeworlds || typeof game.advanced !== 'boolean' || + !registeredRoster(game) || !optionalFlag(game.ecazTreachery) || + (game.techTokens && game.players.length < 3) || (game.strongholdCards && !game.advanced) || + (!game.nexusCards && game.discoveryEnabled !== true) || + (game.discoveryEnabled !== true && (game.discoveryEnabled || game.discoveries || game.discoveryStash || game.greatMaker)) || + game.semutaPreview || game.advancedPreview || game.kullPreview || + game.nexusKullPreview || game.richeseBetrayalPreview) return false; + if (![game.ecazOccupyPreview, game.moritaniAssassinatePreview, game.homeworldOccupationPreview, + game.homeworldTupilePreview, game.homeworldEcazVictoryPreview, game.spiceBankerIncomePreview, + game.guildBetrayalPreview, game.nexusIxianReplacementPreview, game.nexusIxianBetrayalPreview, + game.nexusHarkonnenBetrayalPreview].every(optionalFlag)) return false; + const seated = (faction: string) => game.players.some(player => player.faction === faction); + if ((game.ecazOccupyPreview && !seated('ecaz')) || + (game.moritaniAssassinatePreview && (!game.advanced || !seated('moritani'))) || + (game.homeworldTupilePreview && (game.homeworldOccupationPreview !== true || !seated('choam'))) || + (game.homeworldEcazVictoryPreview && (game.homeworldOccupationPreview !== true || !seated('ecaz'))) || + (game.spiceBankerIncomePreview && !game.leaderSkills) || + (game.guildBetrayalPreview && (!game.nexusCards || !seated('guild'))) || + (game.nexusIxianReplacementPreview && (!game.nexusCards || seated('ixians'))) || + (game.nexusIxianBetrayalPreview && (!game.nexusCards || !seated('ixians'))) || + (game.nexusHarkonnenBetrayalPreview && (!game.nexusCards || !seated('harkonnen')))) return false; + + if (game.leaderSkills) return homeworldLeaderSkillsProfile(game) && + (game.discoveryEnabled !== true || independentDiscoveryProfile(game) || + classicDiscoveryLeaderSkillsProfile(game) || nativeDiscoveryLeaderSkillsProfile(game) || + classicNexusLeaderSkillsProfile(game)); + if (game.discoveryEnabled === true) return homeworldDiscoveryProfile(game) || + classicDiscoveryNexusProfile(game) || pairedDiscoveryNexusProfile(game); + return nexusCoreProfile(game) || classicNexusModulesProfile(game) || pairedNexusModulesProfile(game) || + singleE1E2NexusProfile(game) || mixedE1E2NexusProfile(game) || standaloneE3NexusProfile(game) || + pairedE3NexusProfile(game) || mixedE3NexusProfile(game) || + // The original paired initializer also admits bare E1/E2 World/Nexus. + // This public configuration predicate describes that same undealt roster. + pairedNexusLeaderSkillsProfile(game); +} diff --git a/game/reference.ts b/game/reference.ts index 463cc6c2..9ebe08d7 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -4816,6 +4816,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Clean World starts admit registered classic/no-deck and selected-family paired/mixed/all-three rosters with optional independent cards, Tech3–6 and Advanced Strongholds2–6. Skills or Nexus are not incidental prerequisites. Fresh setup includes original occupation qualification, bank/percentage benefits, native Tupile and Ecaz victory state alongside physical locations; existing played games are not retrofitted. Original module-specific profiles and complete-mode gates stay separate.', 'Fresh World all14 uses actual registered seats and selected ordinary decks in Basic/Advanced, including mixed Ecaz/Moritani with E1/E2 natives. Neither Nexus, independent cards, one exact family nor a seated native from every selected deck is required. Original Tech3+/Advanced Strongholds2+ may remain. Existing no-card Nexus/Discovery and Semuta keep their separate limits. Actual hands, Ix card-before-training and native five-disc assignment remain; Auditor/shared Duke never train. Native Suk saves stay at that home; external Emperor returns retain original typed destinations. Nested locations gain no ordinary Stronghold effect or invented saved history.', 'Fresh clean registered World/all14 starts also include original occupation benefit state beside normal Banker. Admission and native role limits remain. Real other-payer Bank payments can earn a deferred skill grant; occupied Collection and phase tokens do not become immediate Banker cash. Actual Mentat settles the original skill receipt once. Nexus/Discovery/Semuta contexts retain their own scope; played games are not retrofitted.', + 'Existing admitted World starts with Nexus and/or Discoveries also initialize original occupation benefits through fresh setup, optionally with Skills and original phase modules. Occupied Collection, ordinary deposits, nested entry, earned Nexus/Stronghold cards and deferred Banker retain their separate events. Original start/role limits remain; existing occupation history is never replaced and played games are not converted.', '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.', 'Registered selected-family World all14 composes independent36/38/50-card setup and original Tech3+/Advanced Strongholds2+, including mixed E3. Clean bare World cards now use the registered selected-family profile with optional phase modules and original occupation state. Recruits uses current native rates/typed limits and recorded paid/free use; Banker and phase-end Axlotl remain separate. Reinforcements seals exact normal-first sources and excludes an own-World fee from dial/casualties while preserving original face/support. Suk rescues committed losses, not fees. Harass returns actual visitors through native custody and is forbidden on every own World. Original Nexus/Discovery/Semuta and role/effect guards remain.', '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.', @@ -5009,7 +5010,7 @@ export const RULE_TOPICS: RuleTopic[] = [ status: 'Partial', detail: "Basic Tupile current-sole follow-up passes70/70 cases in10 files, types/lint/build, two original public gameplay/rendering programmes and four legal cleanup profiles. Its provisional policy and broader acceptance limits stay explicit. Earlier mixed E1/E2 and paired E3 Nexus added31 original physical programmes plus2 Loyalty-stock regressions. Final489/489 affected checks across42 files pass with types/lint. All64 original families finish258/288 six-seat games with338931 accepted/339426 attempted actions and9013 JSON across three frozen trees and preserved-prefix continuations. All30 remaining source-guard traces are read. Actual390px original Ixian offer/training, typed native and foreign-world payment/free Suboid plan and paired Ecaz foreign entry/living shared Duke preserve20 physical counters. Human Ixian loses, so no winning Suk rescue is claimed. Earlier qualified evidence remains canonical; full rules, assurance, calibration and deployed acceptance remain open. Current Junction follow-up passes131/131 affected cases and14 runtime/policy paths (11 original/3 controlled), with four actual original-action390px journeys. No saved/deployed acceptance is claimed.", - evidence: ['tests/homeworld-occupation-skills-profile.test.ts', 'tests/world-skills-occupation-runtime.test.ts', 'tests/world-skills-occupation-controls.test.tsx', 'tests/world-skills-occupation-income.test.ts', 'tests/homeworld-module-profile.test.ts', 'tests/homeworld-phase-modules-runtime.test.ts', 'tests/homeworld-phase-modules-controls.test.tsx', 'tests/homeworld-phase-occupation-income.test.ts', 'tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/homeworld-classic-skills-runtime.test.ts', + evidence: ['tests/homeworld-occupation-modules-profile.test.ts', 'tests/world-module-occupation-runtime.test.ts', 'tests/world-module-occupation-controls.test.tsx', 'tests/world-module-occupation-income.test.ts', 'tests/homeworld-occupation-skills-profile.test.ts', 'tests/world-skills-occupation-runtime.test.ts', 'tests/world-skills-occupation-controls.test.tsx', 'tests/world-skills-occupation-income.test.ts', 'tests/homeworld-module-profile.test.ts', 'tests/homeworld-phase-modules-runtime.test.ts', 'tests/homeworld-phase-modules-controls.test.tsx', 'tests/homeworld-phase-occupation-income.test.ts', 'tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', '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', diff --git a/tests/fixture-mixed-phase-modules.ts b/tests/fixture-mixed-phase-modules.ts index d42cc1aa..a2288315 100644 --- a/tests/fixture-mixed-phase-modules.ts +++ b/tests/fixture-mixed-phase-modules.ts @@ -20,6 +20,8 @@ export type MixedPhaseModulesOptions = { homeworlds?: boolean; skills?: boolean; training?: Partial>; + nexus?: boolean; + discoveries?: boolean; }; export const MIXED_PHASE_NATIVE_FACTIONS = ['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani'] as const; export const actMixedPhaseModules = actAdvisorPreparation; @@ -28,7 +30,8 @@ export const mixedPhaseModulesPlayer = (game: Game, actor: string) => { }; export const mixedPhaseModulesClean = (game: Game) => !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard && !game.pendingRevival && !game.truthtrance && - !game.pendingShipment && !game.pendingKarama && !game.currentAuctionSale; + !game.pendingShipment && !game.pendingKarama && !game.currentAuctionSale && + game.nexusCards?.phase?.stage !== 'drawing'; /** Current physical zones, excluding the ordinary auction's historical aliases. */ export function mixedPhaseModulesInventory(game: Game): string[] { @@ -43,8 +46,19 @@ export function mixedPhaseModulesInventory(game: Game): string[] { /** Native decisions only. Optional Terror placement is declined so the positive * shipment source does not depend on a separately selected Terror effect. */ export function nextMixedPhaseModules(game: Game): MixedPhaseModulesStep | null { + if (!game.phaseOpening && !game.response && !game.decision && !game.truthtrance && + !game.pendingTreacheryDiscard && game.nexusCards?.phase?.stage === 'drawing') { + const phase = game.nexusCards.phase, actor = phase.eligible.find(id => !phase.done.includes(id)); + assert.ok(actor); + return { actor, action: { type: 'nexusCardChoice', turn: game.turn, + card: game.nexusCards.cards!.hands[actor] ?? null, choice: 'keep', ownRedraws: 0 } }; + } if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard && 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 === 'moritaniSetup') return { actor: d.player, action: { type: 'decision', territory: 'carthag', sector: territory('carthag').sectors[0] } }; if (d.kind === 'moritaniPlacement' || d.kind === 'choamStorm' || d.kind === 'choamMovement' || @@ -103,6 +117,8 @@ export function createMixedPhaseModulesFixture(options: MixedPhaseModulesOptions const act = (actor: string, action: Action) => { game = actMixedPhaseModules(game, actor, action, actions); }; if (options.homeworlds) act(host, { type: 'homeworlds', enabled: true }); if (options.skills) act(host, { type: 'moduleOption', module: 'leaderSkills', enabled: true }); + if (options.nexus) act(host, { type: 'moduleOption', module: 'nexusCards', enabled: true }); + if (options.discoveries) act(host, { type: 'moduleOption', module: 'discoveries', enabled: true }); if (options.independent) act(host, { type: 'moduleOption', module: 'ecazTreachery', enabled: true }); if (options.tech) act(host, { type: 'techTokens', enabled: true }); if (options.strongholds) act(host, { type: 'strongholdCards', enabled: true }); @@ -160,6 +176,7 @@ export function createMixedPhaseModulesFixture(options: MixedPhaseModulesOptions return { game, sourceGame: structuredClone(game), independent: options.independent ?? false, homeworlds: options.homeworlds ?? false, skills: options.skills ?? false, + nexus: options.nexus ?? false, discoveries: options.discoveries ?? false, initial, lobby, setup, afterSetup, firstStorm, originalInventory, originalSpice, spiceOrder, actions, sourceActions: structuredClone(actions), sourceActionCount: actions.length, staging: [] as string[] }; @@ -172,12 +189,14 @@ export function createMixedPhaseModulesFixture(options: MixedPhaseModulesOptions export function createMixedPhaseModulesProgramme(options: { advanced?: boolean; strongholds?: boolean; independent?: boolean; homeworlds?: boolean; skills?: boolean; training?: Partial>; + nexus?: boolean; discoveries?: boolean; } = {}) { const advanced = options.advanced ?? false, strongholds = options.strongholds ?? advanced; const source = createMixedPhaseModulesFixture({ factions: MIXED_PHASE_NATIVE_FACTIONS, expansions: ['ix', 'choam', 'ecaz'], advanced, tech: true, strongholds, independent: options.independent, homeworlds: options.homeworlds, - skills: options.skills, training: options.training }); + skills: options.skills, training: options.training, + nexus: options.nexus, discoveries: options.discoveries }); let game = source.game; const actions = [...source.actions]; const act = (actor: string, action: Action) => { game = actMixedPhaseModules(game, actor, action, actions); }; diff --git a/tests/fixture-world-module-occupation.ts b/tests/fixture-world-module-occupation.ts new file mode 100644 index 00000000..5af3cdd5 --- /dev/null +++ b/tests/fixture-world-module-occupation.ts @@ -0,0 +1,231 @@ +import assert from 'node:assert/strict'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { distance } from '../game/board'; +import { DISCOVERY_CARD_PLACEMENTS } from '../game/discoveries'; +import { discoveryAction } from '../game/discovery-options'; +import { discoveryEntryMoveAction } from '../game/discovery-entry-options'; +import { nativeShipmentSources } from '../game/homeworld-options'; +import { homeworldShipmentChoice } from '../game/homeworld-shipment-options'; +import { homeworldGameIntegrity } from '../game/homeworld-game'; +import { quoteStableHomeworldOccupation } from '../game/homeworld-stable-occupation'; +import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards'; +import { NEXUS_FACTIONS } from '../game/nexus-cards'; +import { ownedTech } from '../game/tech-tokens'; +import { withClassicDiscoveryNexusToken } from './fixture-discovery-classic-nexus'; +import { + actMixedPhaseModules, createMixedPhaseModulesFixture, mixedPhaseModulesClean, + mixedPhaseModulesInventory, mixedPhaseModulesPlayer, nextMixedPhaseModules, + type MixedPhaseModulesStep, type MixedPhaseModulesTransition, +} from './fixture-mixed-phase-modules'; + +export type WorldModuleOccupationOptions = { + advanced?: boolean; nexus?: boolean; discoveries?: boolean; skills?: boolean; + tech?: boolean; strongholds?: boolean; +}; +export type WorldModuleOccupationStep = MixedPhaseModulesStep; +export type WorldModuleOccupationTransition = MixedPhaseModulesTransition; +export const WORLD_MODULE_OCCUPATION_TRAINING = { + atreides: 'warmaster', emperor: 'spice-banker', fremen: 'mentat', +} as const; +export const actWorldModuleOccupation = actMixedPhaseModules; +export const worldModuleOccupationClean = mixedPhaseModulesClean; +export const worldModuleOccupationPlayer = mixedPhaseModulesPlayer; +export const worldModuleOccupationInventory = mixedPhaseModulesInventory; + +export function nextWorldModuleOccupation(game: Game): WorldModuleOccupationStep | null { + return nextMixedPhaseModules(game); +} +export function advanceWorldModuleOccupation(game: Game, until: (state: Game) => boolean, + actions: WorldModuleOccupationStep[] = []): Game { + for (let count = 0; count < 1800; count++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished', 'The original World/module source ended before its owned control'); + const next = nextWorldModuleOccupation(game); assert.ok(next); + game = actWorldModuleOccupation(game, next.actor, next.action, actions); + } + throw Error('The original World/module source did not reach its finite boundary'); +} +export function finishWorldModuleOccupation(game: Game, actions: WorldModuleOccupationStep[] = []): Game { + return advanceWorldModuleOccupation(game, state => state.turn === 2 && state.phase === 0 && + (worldModuleOccupationClean(state) || state.decision?.kind === 'discoveryEntry'), actions); +} +export function worldModuleOccupationDeferred(game: Game, owner = 'emperor') { + return viewGame(game, owner).spiceBankerIncome?.deferred.find(row => row.owner === owner)?.amount ?? 0; +} + +/** Original physical inventories at the source/settled boundaries only. This + * does not manufacture hidden deals or assert complete module certification. */ +export function worldModuleOccupationComponents(game: Game) { + const skills = game.leaderSkills ? [...game.leaderSkills.deck, + ...game.leaderSkills.assignments.map(assignment => assignment.skill), + ...Object.values(game.leaderSkills.offers).flatMap(offer => offer.cards)].sort() : []; + if (game.leaderSkills) assert.deepEqual(skills, LEADER_SKILL_CARDS.map(card => card.id).sort()); + const nexus = game.nexusCards?.cards ? [...game.nexusCards.cards.deck, ...game.nexusCards.cards.discard, + ...Object.values(game.nexusCards.cards.hands).filter(card => card !== null)].sort() : []; + if (game.nexusCards?.cards) assert.deepEqual(nexus, [...NEXUS_FACTIONS].sort()); + const spice = [...game.spiceDeck, ...game.spiceDiscard.flat(), ...(game.spiceResolution?.skipped ?? []), + ...(game.spiceSequence?.skipped ?? [])].map(card => JSON.stringify(card)).sort(); + const tokens = game.discoveries?.tokens.map(token => `${token.id}:${token.face}`).sort() ?? []; + if (game.discoveries) assert.equal(tokens.length, 8); + return { cards: worldModuleOccupationInventory(game), skills, nexus, spice, tokens }; +} + +/** Public classic A/E/F original33 Worlds, optionally all14, Nexus12, + * Discovery7/8, Tech and Advanced Strongholds. Actual A Bank10 evacuates all10 + * Caladan normals into held Arrakeen; actual E Bank1 sends an original native + * normal into empty Caladan. No played army, hand, wallet, skill, qualification, + * owner, income ledger or clock is installed. Only original unopened scalar, + * Skill/Spice and supply-token lotteries are selected and disclosed. */ +export function createWorldModuleOccupationFixture(options: WorldModuleOccupationOptions = {}) { + const advanced = options.advanced ?? false, nexus = options.nexus ?? true; + const discoveries = options.discoveries ?? false, skills = options.skills ?? false; + const tech = options.tech ?? true, strongholds = options.strongholds ?? advanced; + const native = 'atreides', owner = 'emperor', last = 'fremen'; + const world = 'homeworld:atreides', card = 'caladan' as const; + const source = createMixedPhaseModulesFixture({ factions: [native, owner, last], expansions: [], + advanced, homeworlds: true, nexus, discoveries, skills, tech, strongholds, + training: skills ? WORLD_MODULE_OCCUPATION_TRAINING : undefined }); + let game = structuredClone(source.game); + const actions = [...source.actions]; + const act = (actor: string, action: Action) => { game = actWorldModuleOccupation(game, actor, action, actions); }; + const reach = (until: (state: Game) => boolean) => { game = advanceWorldModuleOccupation(game, until, actions); }; + const transition = (run: () => void): WorldModuleOccupationTransition => { + const before = structuredClone(game), from = actions.length; run(); + return { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) }; + }; + const originalComponents = worldModuleOccupationComponents(source.setup); + assert.equal(originalComponents.cards.length, 33); + assert.ok(game.homeworldOccupationPreview && game.homeworldOccupationHistory); + assert.equal(game.homeworldOccupationHistory.qualifications.length, 0); + const moduleSpiceOrder: { position: number; from: number; card: Game['spiceDeck'][number] }[] = []; + let discoveryPlacement: WorldModuleOccupationTransition | null = null; + let token: NonNullable['tokens'][number] | null = null; + const placement = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin']; + if (discoveries) { + // This is still the original unopened first blow, after the real first + // Storm. Its printed token destination is separate from its Spice pile. + assert.equal(game.turn, 1); assert.equal(game.phase, 1); + const from = game.spiceDeck.findIndex(card => 'territory' in card && card.discovery === 'discovery-hagga-basin'); + assert.ok(from >= 0); + const selected = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.unshift(selected); + moduleSpiceOrder.push({ position: 0, from, card: structuredClone(selected) }); + discoveryPlacement = transition(() => { + for (let count = 0; count < 100 && !game.discoveries!.tokens.some(candidate => candidate.face === 'cistern' && candidate.status === 'placed'); count++) { + const next = nextWorldModuleOccupation(game); assert.ok(next); + // Wrap the actual native action directly: the generic source act's + // scalar policy must not override this original physical supply draw. + game = withClassicDiscoveryNexusToken(game, 'cistern', () => applyAction(game, next.actor, next.action)); + actions.push(structuredClone(next)); + } + }); + token = game.discoveries!.tokens.find(candidate => candidate.face === 'cistern' && candidate.status === 'placed') ?? null; + assert.ok(token); assert.equal(token.territory, placement.territory); assert.equal(token.sector, placement.sector); + assert.equal(token.revealedTurn, null); + } + reach(state => state.turn === 1 && state.phase === 5 && state.active === native && worldModuleOccupationClean(state)); + const evacuationSources = nativeShipmentSources(viewGame(game, native), 10, 0); assert.ok(evacuationSources); + const evacuationAction: Action = { type: 'ship', territory: 'arrakeen', sector: 10, + amount: 10, elite: 0, allyPayment: 0, homeworldSources: evacuationSources }; + const evacuation = transition(() => { act(native, evacuationAction); reach(worldModuleOccupationClean); }); + assert.equal(worldModuleOccupationPlayer(game, native).reserves, 0); + assert.equal(worldModuleOccupationPlayer(game, native).forces['arrakeen:10'], 20); + assert.equal(worldModuleOccupationPlayer(evacuation.before, native).spice - worldModuleOccupationPlayer(game, native).spice, 10); + assert.equal(quoteStableHomeworldOccupation(game, card).entitlement, null); + let discoveryMovement: WorldModuleOccupationTransition | null = null; + if (discoveries) { + assert.equal(distance('arrakeen:10', `${placement.territory}:${placement.sector}`), 3); + const movement: Action = { type: 'move', from: 'arrakeen:10', amount: 1, elite: 0, + territory: placement.territory, sector: placement.sector }; + discoveryMovement = transition(() => { act(native, movement); reach(worldModuleOccupationClean); }); + assert.equal(worldModuleOccupationPlayer(game, native).forces['arrakeen:10'], 19); + assert.equal(worldModuleOccupationPlayer(game, native).forces[`${placement.territory}:${placement.sector}`], 1); + } + reach(state => state.phase === 5 && state.active === owner && worldModuleOccupationClean(state)); + const incomingReserveSources = nativeShipmentSources(viewGame(game, owner), 1, 0); assert.ok(incomingReserveSources); + // Native withdrawal quotes enumerate both Emperor Worlds, including a + // zero Salusa allocation. Cross-World shipment selects only positive groups, + // just as the ordinary Homeworld Shipment control does. + const incomingSources = Object.fromEntries(Object.entries(incomingReserveSources) + .filter(([, group]) => group.normal + group.elite > 0)); + const invasionQuote = homeworldShipmentChoice(viewGame(game, owner), world, incomingSources); + assert.equal(invasionQuote.blocked, null); assert.ok(invasionQuote.action); + const invasionAction = invasionQuote.action; + const invasion = transition(() => { act(owner, invasionAction); reach(worldModuleOccupationClean); }); + assert.equal(worldModuleOccupationPlayer(invasion.before, owner).spice - worldModuleOccupationPlayer(game, owner).spice, 1); + assert.deepEqual(game.homeworlds?.custody?.visitors[world]?.[owner], { normal: 1, elite: 0 }); + const qualification = game.homeworldOccupationHistory!.qualifications.find(row => row.world === world && row.player === owner); + assert.ok(qualification, 'The accepted sole arrival earns the original qualification'); + const stable = quoteStableHomeworldOccupation(game, card); + assert.equal(stable.blocked, null); assert.ok(stable.entitlement); + assert.equal(stable.entitlement.qualification, qualification.event); + assert.equal(stable.entitlement.occupier, owner); assert.equal(stable.entitlement.spice, 2); + assert.equal(stable.entitlement.ally, null); + reach(state => state.phase === 5 && state.active === last && worldModuleOccupationClean(state)); + assert.equal(game.order.at(-1), last); + const techOwner = game.techTokens?.heighliners.owner ?? null; + const techAmount = game.techTokens?.heighliners.spice ?? 0; + if (techOwner) assert.equal(techAmount, ownedTech(game.techTokens, techOwner).length); + const bankerSource = game.spiceBankerIncome?.sources.find(receipt => receipt.source.kind === 'shipment' && + receipt.source.bankLegs.some(leg => leg.payer === native && leg.amount === 10)) ?? null; + if (skills) { + assert.ok(bankerSource?.grant); assert.equal(bankerSource.grant.owner, owner); + assert.equal(worldModuleOccupationDeferred(game, owner), 1); + } + const trainingActions = actions.filter(step => step.action.type === 'leaderSkill'); + assert.deepEqual(worldModuleOccupationComponents(game), originalComponents); + homeworldGameIntegrity(game); + return { ...source, game, sourceGame: structuredClone(game), advanced, nexus, discoveries, skills, tech, strongholds, + native, owner, banker: skills ? owner : null, world, card, current: last, actor: last, + action: { type: 'endMovement' } as Action, techOwner, techAmount, + evacuationSources, evacuationAction, evacuation, incomingReserveSources, incomingSources, invasionQuote, invasionAction, invasion, + qualification: structuredClone(qualification), entitlement: structuredClone(stable.entitlement), + moduleSpiceOrder, discoveryPlacement, token: structuredClone(token), discoveryMovement, + bankerSource: structuredClone(bankerSource), training: skills ? WORLD_MODULE_OCCUPATION_TRAINING : null, + trainingActions, originalComponents, actions, sourceActions: structuredClone(actions), sourceActionCount: actions.length, + staging: [] as string[], sourceDisclosure: 'Original public A/E/F base33 setup and optional module controls; unopened Skill/scalar/Spice lotteries only. Discovery selects original Hagga card and Cistern supply lottery, then earns ordinary city-range3 movement, Collection reveal and next-Storm entry. All payments, native counters and occupation events are actual actions.' }; +} +export type WorldModuleOccupationFixture = ReturnType; + +/** Original End Movement, automatic occupied Collection2, actual optional + * inspect/reveal, Mentat Banker and first Stronghold claims. Discovery's next + * Storm entry is captured as its own genuine control, not a quiet-phase claim. */ +export function completeWorldModuleOccupation(f: WorldModuleOccupationFixture, state = f.game) { + let game = state; + const actions: WorldModuleOccupationStep[] = []; + const act = (actor: string, action: Action) => { game = actWorldModuleOccupation(game, actor, action, actions); }; + const reach = (until: (state: Game) => boolean) => { game = advanceWorldModuleOccupation(game, until, actions); }; + const transition = (run: () => void): WorldModuleOccupationTransition => { + const before = structuredClone(game), from = actions.length; run(); + return { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) }; + }; + const collection = transition(() => { act(f.current, f.action); reach(state => state.phase === 7 && worldModuleOccupationClean(state)); }); + const incomeHistory = game.homeworldOccupiedIncomeHistory?.find(history => history.turn === game.turn && + history.queue.some(row => row.world === f.world)); assert.ok(incomeHistory); + const incomeReceipt = incomeHistory.receipts.find(receipt => receipt.world === f.world); assert.ok(incomeReceipt); + let revelation: WorldModuleOccupationTransition | null = null; + if (f.token) revelation = transition(() => { + const inspect = discoveryAction(viewGame(game, f.native), f.token!.id, false); assert.ok(inspect); act(f.native, inspect); + const reveal = discoveryAction(viewGame(game, f.native), f.token!.id, true); assert.ok(reveal); act(f.native, reveal); + assert.equal(game.discoveries!.tokens.find(token => token.id === f.token!.id)!.revealedTurn, 1); + }); + const mentat = transition(() => { game = finishWorldModuleOccupation(game, actions); }); + const bankerCollection = f.bankerSource ? game.spiceBankerIncome?.collections.find(receipt => + receipt.turn === 1 && receipt.grants.includes(f.bankerSource!.cursor)) ?? null : null; + if (f.skills) assert.ok(bankerCollection); + let entryAction: Action | null = null, entry: WorldModuleOccupationTransition | null = null; + const beforeEntry = f.token ? structuredClone(game) : null; + if (f.token) { + assert.equal(game.decision?.kind, 'discoveryEntry'); assert.equal(game.decision!.player, f.native); + const offer = viewGame(game, f.native).discoveryEntry; assert.ok(offer); + entryAction = discoveryEntryMoveAction(viewGame(game, f.native), offer.sources); assert.ok(entryAction); + const selected = entryAction; + entry = transition(() => { act(f.native, selected); }); + assert.equal(worldModuleOccupationPlayer(game, f.native).forces['cistern:0'], 1); + } + assert.equal(game.turn, 2); assert.equal(game.phase, 0); + homeworldGameIntegrity(game); + assert.deepEqual(worldModuleOccupationComponents(game), f.originalComponents); + return { game, collection, incomeHistory: structuredClone(incomeHistory), incomeReceipt: structuredClone(incomeReceipt), + revelation, mentat, bankerCollection: structuredClone(bankerCollection), beforeEntry, entryAction, entry, + actions, actionCount: actions.length }; +} diff --git a/tests/homeworld-classic-nexus-skills-runtime.test.ts b/tests/homeworld-classic-nexus-skills-runtime.test.ts index c49ef785..1c8769b7 100644 --- a/tests/homeworld-classic-nexus-skills-runtime.test.ts +++ b/tests/homeworld-classic-nexus-skills-runtime.test.ts @@ -178,7 +178,11 @@ for (const advanced of [false, true]) { assert.equal(player(done, f.owner).tanks, advanced ? 3 : 4); assert.equal(player(done, f.owner).elites?.tanks ?? 0, advanced ? 1 : 0); assert.equal(done.nexusCards!.cards!.hands[f.owner], 'fremen', 'Battle rescue never spends the unrelated physically held Nexus singleton.'); - const window = revivalWindow(done, f.actions, f.staging), offer = viewGame(window, f.owner).nexusFremenRevival!; + const opened = revivalWindow(done, f.actions, f.staging); + if (opened.revivalOrder) assert.ok(opened.revivalOrder.remaining.includes(f.owner)); + const window = advance(opened, g => g.phase === 4 && clean(g) && + (!g.revivalOrder || g.revivalOrder.remaining[0] === f.owner), f.actions); + const offer = viewGame(window, f.owner).nexusFremenRevival!; assert.equal(offer.blocked, null); assert.equal(viewGame(window, f.opponent).nexusFremenRevival, null); legalPolicies(window, f.owner); diff --git a/tests/homeworld-occupation-modules-profile.test.ts b/tests/homeworld-occupation-modules-profile.test.ts new file mode 100644 index 00000000..707b1b35 --- /dev/null +++ b/tests/homeworld-occupation-modules-profile.test.ts @@ -0,0 +1,154 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + homeworldOccupationModulesProfile as supported, + homeworldOccupationLeaderSkillsProfile as cleanSkills, + homeworldPhaseModulesProfile as clean, + type HomeworldPhaseModulesProfile, +} from '../game/homeworld-module-profile'; +import { independentDiscoveryProfile, nexusCoreProfile } from '../game/faction-module-profile'; +import { + classicNexusLeaderSkillsProfile, + homeworldLeaderSkillsProfile, + pairedNexusLeaderSkillsProfile, +} from '../game/leader-skill-profile'; +import { + classicDiscoveryLeaderSkillsProfile, + classicDiscoveryNexusProfile, + homeworldDiscoveryProfile, + pairedDiscoveryNexusProfile, +} from '../game/discovery-module-profile'; +import { mixedE3NexusProfile, standaloneE3NexusProfile } from '../game/nexus-module-profile'; +import { createTechTokens } from '../game/tech-tokens'; +import { createStrongholdCards } from '../game/stronghold-cards'; + +function profile(factions: readonly string[] = ['atreides', 'emperor', 'guild'], expansions: readonly string[] = [], + extra: Partial = {}): HomeworldPhaseModulesProfile { + return { advanced: false, expansions, players: factions.map(faction => ({ faction })), + homeworlds: { custody: null }, ...extra }; +} +const nexus = () => ({ cards: null, phase: null }); + +void test('original classic World Nexus accepts its initialized core reactions with and without Skills', () => { + const game = profile(['atreides', 'harkonnen', 'guild'], [], { advanced: true, nexusCards: nexus(), + techTokens: createTechTokens(), strongholdCards: createStrongholdCards(), + homeworldOccupationPreview: true, nexusIxianReplacementPreview: true, + nexusHarkonnenBetrayalPreview: true, guildBetrayalPreview: true }); + assert.equal(nexusCoreProfile(game), true); assert.equal(supported(game), true); + const trained = { ...game, leaderSkills: { deck: [], assignments: [] }, spiceBankerIncomePreview: true }; + assert.equal(homeworldLeaderSkillsProfile(trained), true); assert.equal(supported(trained), true); + assert.equal(clean(game), false); assert.equal(cleanSkills(trained), false); +}); + +void test('original classic Discovery retains separate untrained and all14 World admission', () => { + const game = profile(undefined, undefined, { discoveryEnabled: true, techTokens: createTechTokens() }); + assert.equal(homeworldDiscoveryProfile(game), true); assert.equal(supported(game), true); + const trained = { ...game, leaderSkills: { deck: [], offers: {} }, spiceBankerIncomePreview: true }; + assert.equal(classicDiscoveryLeaderSkillsProfile(trained), true); + assert.equal(homeworldLeaderSkillsProfile(trained), true); assert.equal(supported(trained), true); + assert.equal(supported({ ...game, discoveryEnabled: false, discoveries: {} }), false); + assert.equal(supported({ ...game, discoveryEnabled: 'true' }), false); +}); + +void test('classic World Nexus and Discovery use their original joint profiles rather than hiding either module', () => { + const game = profile(undefined, undefined, { advanced: true, nexusCards: nexus(), discoveryEnabled: true, + techTokens: createTechTokens(), strongholdCards: createStrongholdCards() }); + assert.equal(classicDiscoveryNexusProfile(game), true); assert.equal(supported(game), true); + const trained = { ...game, leaderSkills: true, spiceBankerIncomePreview: true }; + assert.equal(classicNexusLeaderSkillsProfile(trained), true); + assert.equal(homeworldLeaderSkillsProfile(trained), true); assert.equal(supported(trained), true); + assert.equal(nexusCoreProfile(game), false, 'The non-Discovery core does not manufacture this separate admission'); +}); + +void test('original native Nexus includes bare paired E1 and existing standalone and mixed E3 World rosters', () => { + const paired = profile(['ixians', 'tleilaxu', 'emperor'], ['ix'], { nexusCards: nexus() }); + assert.equal(pairedNexusLeaderSkillsProfile(paired), true, 'The original paired configuration needs no phase module'); + assert.equal(supported(paired), true); + const discovery = { ...paired, discoveryEnabled: true }; + assert.equal(pairedDiscoveryNexusProfile(discovery), true); assert.equal(supported(discovery), true); + const standalone = profile(['moritani', 'emperor', 'guild'], ['ecaz'], { advanced: true, + nexusCards: nexus(), moritaniAssassinatePreview: true }); + assert.equal(standaloneE3NexusProfile(standalone), true); assert.equal(supported(standalone), true); + const mixed = profile(['ixians', 'choam', 'ecaz', 'moritani'], ['ix', 'choam', 'ecaz'], { + advanced: true, nexusCards: nexus(), techTokens: createTechTokens(), strongholdCards: createStrongholdCards(), + ecazOccupyPreview: true, moritaniAssassinatePreview: true, homeworldOccupationPreview: true, + homeworldTupilePreview: true, homeworldEcazVictoryPreview: true }); + assert.equal(mixedE3NexusProfile(mixed), true); assert.equal(supported(mixed), true); + const trained = { ...mixed, leaderSkills: true, discoveryEnabled: true, spiceBankerIncomePreview: true }; + assert.equal(homeworldLeaderSkillsProfile(trained), true); assert.equal(supported(trained), true); +}); + +void test('independent cards retain already-admitted selected and unused families beside Nexus or Discovery', () => { + const selected = profile(['ecaz', 'ixians', 'emperor'], ['ix', 'choam', 'ecaz'], { + nexusCards: nexus(), ecazTreachery: true, nexusIxianBetrayalPreview: true, + ecazOccupyPreview: true, homeworldOccupationPreview: true, homeworldEcazVictoryPreview: true }); + assert.equal(nexusCoreProfile(selected), true); assert.equal(supported(selected), true); + const unused = profile(undefined, ['ix', 'choam', 'ecaz'], { + discoveryEnabled: true, ecazTreachery: true, techTokens: createTechTokens() }); + assert.equal(independentDiscoveryProfile(unused), true); assert.equal(supported(unused), true); + const both = profile(['ecaz', 'moritani', 'choam'], ['ix', 'choam', 'ecaz'], { + advanced: true, ecazTreachery: true, nexusCards: nexus(), discoveryEnabled: true, leaderSkills: true, + techTokens: createTechTokens(), strongholdCards: createStrongholdCards(), spiceBankerIncomePreview: true, + nexusIxianReplacementPreview: true, moritaniAssassinatePreview: true, ecazOccupyPreview: true, + homeworldOccupationPreview: true, homeworldTupilePreview: true, homeworldEcazVictoryPreview: true }); + assert.equal(independentDiscoveryProfile(both), true); assert.equal(homeworldLeaderSkillsProfile(both), true); + assert.equal(supported(both), true); +}); + +void test('occupation selection preserves original Advanced roles, family requirements and phase limits', () => { + const guarded = profile(['ecaz', 'harkonnen', 'guild'], ['ecaz'], { + advanced: true, nexusCards: nexus(), leaderSkills: true }); + assert.equal(homeworldLeaderSkillsProfile(guarded), false); assert.equal(supported(guarded), false); + const discoveryGuard = profile(['moritani', 'harkonnen', 'guild'], ['ecaz'], { + advanced: true, discoveryEnabled: true }); + assert.equal(homeworldDiscoveryProfile(discoveryGuard), false); assert.equal(supported(discoveryGuard), false); + for (const game of [ + profile(['ixians', 'emperor'], [], { nexusCards: nexus() }), + profile(['ixians', 'emperor', 'guild'], ['ix', 'choam'], { nexusCards: nexus() }), + profile(['atreides', 'emperor'], [], { nexusCards: nexus(), techTokens: createTechTokens() }), + profile(undefined, undefined, { nexusCards: nexus(), strongholdCards: createStrongholdCards() }), + profile(['atreides', 'atreides'], [], { nexusCards: nexus() }), + profile(undefined, ['ix', 'ix'], { discoveryEnabled: true }), + ]) assert.equal(supported(game), false); + assert.equal(supported(profile(['atreides', 'emperor'], [], { + advanced: true, nexusCards: nexus(), strongholdCards: createStrongholdCards() })), true); +}); + +void test('unrelated previews and mismatched native, parent, reaction or Banker stamps remain outside occupation permission', () => { + const game = profile(undefined, undefined, { nexusCards: nexus() }); + for (const field of ['semutaPreview', 'advancedPreview', 'kullPreview', 'nexusKullPreview', + 'richeseBetrayalPreview'] as const) assert.equal(supported({ ...game, [field]: true }), false, field); + for (const extra of [ + { ecazOccupyPreview: true }, { moritaniAssassinatePreview: true }, + { homeworldOccupationPreview: true, homeworldTupilePreview: true }, + { homeworldOccupationPreview: true, homeworldEcazVictoryPreview: true }, + { nexusIxianBetrayalPreview: true }, { nexusHarkonnenBetrayalPreview: true }, + { spiceBankerIncomePreview: true }, { ecazTreachery: 'true' }, + ]) assert.equal(supported({ ...game, ...extra }), false); + assert.equal(supported(profile(['atreides', 'emperor', 'harkonnen'], [], { + nexusCards: nexus(), guildBetrayalPreview: true })), false); + assert.equal(supported(profile(['ixians', 'emperor', 'guild'], ['ix'], { + nexusCards: nexus(), ecazTreachery: true, nexusIxianReplacementPreview: true })), false); + for (const field of ['homeworldOccupationPreview', 'homeworldTupilePreview', 'homeworldEcazVictoryPreview', + 'ecazOccupyPreview', 'moritaniAssassinatePreview', 'spiceBankerIncomePreview', 'guildBetrayalPreview', + 'nexusIxianReplacementPreview', 'nexusIxianBetrayalPreview', 'nexusHarkonnenBetrayalPreview'] as const) + assert.equal(supported({ ...game, [field]: 'forged' }), false, field); +}); + +void test('live native Discovery, active printed Sandtrout and occupied custody stay configuration-neutral without changing clean helpers', () => { + const custody = { visitors: { 'homeworld:ixians': { emperor: { normal: 1, elite: 1 } } } }; + const discoveries = { turn: 2, tokens: [], encounters: [] }; + const game = profile(['ixians', 'tleilaxu', 'emperor'], ['ix'], { nexusCards: nexus(), discoveryEnabled: true, + homeworlds: { custody, historyVersion: 1 }, homeworldOccupationPreview: true, sandtrout: true, + discoveries, discoveryStash: {}, greatMaker: { turn: 2 } }); + assert.equal(pairedDiscoveryNexusProfile(game), true); + const before = structuredClone(game); Object.freeze(custody); Object.freeze(game.players); Object.freeze(game); + assert.equal(supported(game), true); assert.deepEqual(game, before); + assert.equal((game.homeworlds as { custody: unknown }).custody, custody); assert.equal(game.discoveries, discoveries); + const plain = profile(); const trained = { ...plain, leaderSkills: true }; + assert.equal(clean(plain), true); assert.equal(cleanSkills(trained), true); + assert.equal(supported(plain), false); assert.equal(supported(trained), false); + assert.equal(clean({ ...plain, sandtrout: true }), false); assert.equal(cleanSkills({ ...trained, sandtrout: true }), false); + for (const value of [null, {}, { ...game, expansions: 'ix' }, { ...game, players: [{ faction: 'ixians' }, null] }]) + assert.equal(supported(value as unknown as HomeworldPhaseModulesProfile), false); +}); diff --git a/tests/homeworld-paired-nexus-runtime.test.ts b/tests/homeworld-paired-nexus-runtime.test.ts index 92a64188..056ab26f 100644 --- a/tests/homeworld-paired-nexus-runtime.test.ts +++ b/tests/homeworld-paired-nexus-runtime.test.ts @@ -336,10 +336,28 @@ void test('actual CHOAM Special fuels matching Cunning once; original native pay assert.equal(game.lastBattleContext!.winner, f.native); assert.equal(player(game, f.native).tanks, player(beforePlans, f.native).tanks + 2); assert.deepEqual(army(game, f.native, 'homeworld:guild'), { normal: 1, elite: 0 }); - // Collection may already have opened; separate its stronghold/harvest income from support. + // Collection may already have opened. The actual victory also empties + // Junction, leaving CHOAM's one survivor as its qualified sole occupier. + // Its printed two-spice occupied award is separate from support and from + // the held Arrakeen card at another battle source. const collection = game.phase === 7 && clean(game) ? quoteSpiceCollection(game).receipts.find(r => r.player === f.native) : undefined; + const occupied = game.homeworldOccupiedIncomeHistory!.slice(beforePlans.homeworldOccupiedIncomeHistory!.length); + assert.equal(occupied.length, 1, 'The real Collection phase records its occupied-World award once.'); + const junction = occupied[0].queue.find(row => row.world === 'homeworld:guild'); assert.ok(junction); + assert.equal(junction.card, 'junction'); assert.equal(junction.native, f.guild); + assert.equal(junction.occupier, f.native); assert.equal(junction.spice, 2); + assert.deepEqual(army(game, f.guild, 'homeworld:guild'), { normal: 0, elite: 0 }); + const qualification = game.homeworldOccupationHistory!.qualifications.find(row => + row.event === junction.qualification && row.world === junction.world && row.player === f.native); + assert.ok(qualification); assert.equal(qualification.cause, 'sole'); + assert.ok(!beforePlans.homeworldOccupationHistory!.qualifications.some(row => row.event === qualification.event), + 'The actual World battle earns the qualification after the plans were sealed.'); + const award = occupied[0].receipts.find(row => row.world === junction.world); + assert.ok(award?.status === 'settled'); + assert.deepEqual(award.credits, [{ player: f.native, amount: 2 }]); + const occupiedIncome = award.credits.filter(credit => credit.player === f.native).reduce((sum, credit) => sum + credit.amount, 0); assert.equal(player(game, f.native).spice, player(beforePlans, f.native).spice - 2 + - (collection?.strongholds ?? 0) + (collection?.collected ?? 0)); + (collection?.strongholds ?? 0) + (collection?.collected ?? 0) + occupiedIncome); assert.equal(game.discard.filter(c => c.id === fuel.id).length, 1); inventory(game); }); diff --git a/tests/homeworld-standalone-nexus-runtime.test.ts b/tests/homeworld-standalone-nexus-runtime.test.ts index e7aa69f4..617ca933 100644 --- a/tests/homeworld-standalone-nexus-runtime.test.ts +++ b/tests/homeworld-standalone-nexus-runtime.test.ts @@ -293,6 +293,11 @@ void test('original Moritani private rescue → assassination → exact death Sk g = advance(g, s => s.turn === turn + 1 && s.phase === 1 && clean(s), f.actions); orderSpice(g); g = advance(g, s => s.phase === 4 && clean(s), f.actions); + if (g.revivalOrder) { + assert.ok(g.revivalOrder.remaining.includes(f.opponent)); + g = advance(g, s => s.phase === 4 && clean(s) && + s.revivalOrder?.remaining[0] === f.opponent, f.actions); + } if (index < discs.length - 1) reject(g, f.opponent, { type: 'reviveLeader', leader: target }, /All leaders must die/); } assert.equal(player(g, f.opponent).leaders.filter(l => l.dead).length, 5); diff --git a/tests/world-module-occupation-controls.test.tsx b/tests/world-module-occupation-controls.test.tsx new file mode 100644 index 00000000..b4d269f8 --- /dev/null +++ b/tests/world-module-occupation-controls.test.tsx @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { registerHooks } from 'node:module'; +import { readFileSync } from 'node:fs'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { viewGame } from '../game/engine'; +import { createMixedPhaseModulesFixture } from './fixture-mixed-phase-modules'; + +const aliases = registerHooks({ + resolve(specifier, context, next) { + return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context); + }, + load(url, context, next) { + if (!url.endsWith('.module.css')) return next(url, context); + const css = readFileSync(new URL(url), 'utf8'); + const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]])); + return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true }; + }, +}); +const { GameTable } = await import('../components/game-table'); +aliases.deregister(); + +// React SSR confirms the original controls. Recorded public actions exercise +// authoritative Start/training; no DOM callback execution is claimed here. +void test('original World Nexus and Discovery controls retain real public Start and native trained leaders', () => { + for (const advanced of [false, true]) { + const f = createMixedPhaseModulesFixture({ factions: ['atreides', 'emperor', 'fremen'], expansions: [], + advanced, homeworlds: true, nexus: true, discoveries: advanced, skills: advanced, + tech: true, strongholds: advanced, + ...(advanced ? { training: { atreides: 'warmaster', emperor: 'mentat', fremen: 'swordmaster-of-ginaz' } as const } : {}) }); + const render = (game: typeof f.game) => renderToStaticMarkup(createElement(GameTable, { + game: viewGame(game, game.host), busy: false, send: async () => {}, onExit() {}, + })); + const html = render(f.lobby), labels = [...html.matchAll(/]*>([\s\S]*?)<\/label>/g)]; + for (const text of ['Homeworlds · preview', 'Nexus Cards', 'Tech tokens · optional', + ...(advanced ? ['Discoveries', 'Leader Skills · all fourteen skill cards', 'Stronghold Cards'] : [])]) { + const label = labels.find(([, children]) => children.includes(text)); assert.ok(label, text); + assert.match(label[1], /]*checked=""/); + } + const text = advanced ? 'Begin Advanced preview' : 'Begin game'; + const button = [...html.matchAll(/]*)>([\s\S]*?)<\/button>/g)] + .find(([, , children]) => children.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim() === text); + assert.ok(button); assert.doesNotMatch(button[1], /(?:^|\s)disabled(?:=""|\s|$)/); + assert.ok(f.actions.some(step => step.action.type === 'start' && !!step.action.advancedPreview === advanced)); + assert.equal(f.setup.homeworldOccupationPreview, true); + if (advanced) { + const assignment = f.afterSetup.leaderSkills!.assignments.find(assignment => assignment.owner === f.game.host); assert.ok(assignment); + const native = f.afterSetup.players.find(player => player.id === assignment.owner)!.leaders.find(leader => leader.id === assignment.leader); assert.ok(native); + const playing = render(f.game); + assert.match(playing, /Public Leader Skill assignments/); assert.match(playing, /Warmaster/); + assert.ok(playing.includes(native.name)); assert.match(playing, /Inspect Stronghold Cards/); + } + } +}); diff --git a/tests/world-module-occupation-income.test.ts b/tests/world-module-occupation-income.test.ts new file mode 100644 index 00000000..f683a330 --- /dev/null +++ b/tests/world-module-occupation-income.test.ts @@ -0,0 +1,118 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { viewGame } from '../game/engine'; +import { quoteSpiceCollection } from '../game/board-resolution-quote'; +import { TECH_TOKENS } from '../game/tech-tokens'; +import { + completeWorldModuleOccupation, createWorldModuleOccupationFixture, + worldModuleOccupationComponents as components, worldModuleOccupationDeferred as deferred, + worldModuleOccupationPlayer as player, +} from './fixture-world-module-occupation'; + +const cases = [ + { label: 'Basic World Nexus Tech', advanced: false, nexus: true, discoveries: false, skills: false, tech: true, strongholds: false }, + { label: 'Basic World Discovery Skills', advanced: false, nexus: false, discoveries: true, skills: true, tech: false, strongholds: false }, + { label: 'Advanced World Nexus Discovery Skills Tech Strongholds', advanced: true, nexus: true, discoveries: true, skills: true, tech: true, strongholds: true }, +] as const; + +for (const options of cases) { + void test(`native ${options.label} earns Caladan occupation income beside original module continuations`, () => { + const f = createWorldModuleOccupationFixture(options); + assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup'); + assert.deepEqual(f.game.players.map(seat => seat.faction), ['atreides', 'emperor', 'fremen']); + assert.deepEqual(f.game.expansions, []); + assert.ok(f.game.homeworlds && f.game.homeworldOccupationPreview); + assert.equal(!!f.game.nexusCards, options.nexus); assert.equal(!!f.game.discoveryEnabled, options.discoveries); + assert.equal(!!f.game.leaderSkills, options.skills); assert.equal(!!f.game.techTokens, options.tech); + assert.equal(!!f.game.strongholdCards, options.strongholds); + assert.equal(f.originalComponents.cards.length, 33); + assert.equal(f.originalComponents.skills.length, options.skills ? 14 : 0); + assert.equal(f.originalComponents.nexus.length, options.nexus ? 12 : 0); + assert.equal(f.originalComponents.spice.length, options.discoveries ? 28 : 21); + assert.equal(f.originalComponents.tokens.length, options.discoveries ? 8 : 0); + assert.deepEqual(components(f.game), f.originalComponents); assert.deepEqual(f.staging, []); + assert.equal(f.sourceActionCount, f.sourceActions.length); + assert.deepEqual(f.evacuationSources, { 'homeworld:atreides': { normal: 10, elite: 0 } }); + assert.equal(player(f.evacuation.after, f.native).reserves, 0); + assert.equal(player(f.evacuation.after, f.native).forces['arrakeen:10'], 20); + assert.equal(player(f.evacuation.before, f.native).spice - player(f.evacuation.after, f.native).spice, 10); + assert.deepEqual(f.incomingSources, { 'homeworld:emperor': { normal: 1, elite: 0 } }); + assert.equal(player(f.invasion.before, f.owner).spice - player(f.invasion.after, f.owner).spice, 1); + assert.equal(player(f.invasion.before, f.owner).reserves - player(f.invasion.after, f.owner).reserves, 1); + assert.equal(player(f.invasion.after, f.owner).elites?.reserves, player(f.invasion.before, f.owner).elites?.reserves); + assert.deepEqual(f.invasion.after.homeworlds?.custody?.salusa, f.invasion.before.homeworlds?.custody?.salusa); + assert.deepEqual(f.invasion.after.homeworlds?.custody?.visitors[f.world]?.[f.owner], { normal: 1, elite: 0 }); + assert.equal(f.entitlement.qualification, f.qualification.event); assert.equal(f.entitlement.spice, 2); + assert.equal(f.entitlement.occupier, f.owner); assert.equal(f.entitlement.ally, null); + if (options.skills) { + assert.equal(f.trainingActions.length, 3); + for (const [actor, skill] of Object.entries(f.training!)) { + const assignment = viewGame(f.game, actor).leaderSkills!.assignments.find(row => row.owner === actor); + assert.ok(assignment); assert.equal(assignment.skill, skill); + assert.equal(assignment.faceUp, true); assert.equal(assignment.captured, false); + assert.ok(f.trainingActions.some(step => step.actor === actor && step.action.skill === skill && step.action.leader === assignment.leader)); + } + assert.deepEqual(f.bankerSource!.source.bankLegs, [{ payer: f.native, amount: 10 }]); + assert.equal(f.bankerSource!.grant!.owner, f.owner); assert.equal(deferred(f.game, f.owner), 1); + assert.equal(player(f.evacuation.after, f.owner).spice, player(f.evacuation.before, f.owner).spice); + } else assert.equal(f.bankerSource, null); + + const before = structuredClone(f.game), ordinary = quoteSpiceCollection(f.game).receipts; + const techBefore = structuredClone(f.game.techTokens); + const result = completeWorldModuleOccupation(f); + assert.deepEqual(f.game, before, 'The native source remains reusable before its actual final callback'); + assert.deepEqual(result.collection.steps[0], { actor: f.current, action: f.action }); + assert.equal(result.collection.after.phase, 7); + assert.deepEqual(result.incomeReceipt, { world: f.world, status: 'settled', ownAmount: 2, + credits: [{ player: f.owner, amount: 2 }] }); + for (const seat of before.players) { + const receipt = ordinary.find(receipt => receipt.player === seat.id); + const nativeIncome = receipt ? receipt.strongholds + receipt.collected : 0; + assert.equal(player(result.collection.after, seat.id).spice - seat.spice, + nativeIncome + (seat.id === f.owner ? 2 : 0) + (seat.id === f.techOwner ? f.techAmount : 0)); + } + assert.equal(player(result.collection.after, f.native).spice - player(before, f.native).spice, + (options.advanced ? 2 : 0) + (f.techOwner === f.native ? f.techAmount : 0), 'Ordinary Arrakeen income remains separate'); + assert.equal(deferred(result.collection.after, f.owner), options.skills ? 1 : 0); + if (options.tech) { + assert.ok(f.techOwner); assert.ok(f.techAmount > 0); + assert.equal(result.collection.after.techTokens!.heighliners.spice, 0); + for (const { id } of TECH_TOKENS) assert.equal(result.game.techTokens![id].owner, techBefore![id].owner); + } + assert.equal(player(result.mentat.after, f.owner).spice - player(result.mentat.before, f.owner).spice, options.skills ? 1 : 0); + if (options.skills) { + assert.deepEqual(result.bankerCollection!.credits, [{ owner: f.owner, amount: 1 }]); + assert.deepEqual(result.bankerCollection!.grants, [f.bankerSource!.cursor]); + assert.equal(result.bankerCollection!.phase, 8); + assert.equal(result.game.spiceBankerIncome!.sources.filter(receipt => receipt.grant !== null).length, 1); + } + assert.equal(deferred(result.game, f.owner), 0); + if (options.strongholds) { + assert.equal(result.game.strongholdCards!.claimedTurn, 1); + assert.equal(result.game.strongholdCards!.owners.arrakeen, f.native); + assert.equal(result.game.strongholdCards!.owners.sietch_tabr, 'fremen'); + } + if (options.discoveries) { + assert.equal(f.moduleSpiceOrder.length, 1); assert.ok(f.discoveryPlacement && f.discoveryMovement && f.token); + assert.equal(f.token.face, 'cistern'); assert.equal(f.token.revealedTurn, null); + assert.deepEqual(f.discoveryMovement.steps[0], { actor: f.native, action: { + type: 'move', from: 'arrakeen:10', amount: 1, elite: 0, territory: 'gara_kulon', sector: 8 } }); + assert.equal(player(f.game, f.native).forces['gara_kulon:8'], 1); + assert.equal(player(f.game, f.native).forces['arrakeen:10'], 19); + assert.ok(result.revelation && result.beforeEntry && result.entry && result.entryAction); + assert.deepEqual(result.revelation.steps.map(step => step.action), [ + { type: 'discovery', token: f.token.id, reveal: false }, { type: 'discovery', token: f.token.id, reveal: true }, + ]); + assert.equal(result.beforeEntry.decision?.kind, 'discoveryEntry'); + assert.equal(result.beforeEntry.decision!.player, f.native); + assert.deepEqual(result.entry.steps[0], { actor: f.native, action: result.entryAction }); + assert.equal(player(result.entry.after, f.native).forces['gara_kulon:8'], undefined); + assert.equal(player(result.entry.after, f.native).forces['cistern:0'], 1); + assert.equal(player(result.entry.after, f.native).spice, player(result.entry.before, f.native).spice); + assert.equal(player(result.entry.after, f.native).moved, player(result.entry.before, f.native).moved); + assert.equal(player(result.entry.after, f.native).reserves, player(result.entry.before, f.native).reserves); + } else assert.equal(result.entry, null); + assert.equal(result.game.turn, 2); assert.equal(result.game.phase, 0); + assert.deepEqual(components(result.game), f.originalComponents); + }); +} diff --git a/tests/world-module-occupation-runtime.test.ts b/tests/world-module-occupation-runtime.test.ts new file mode 100644 index 00000000..331e27f4 --- /dev/null +++ b/tests/world-module-occupation-runtime.test.ts @@ -0,0 +1,105 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, viewGame, type Game } from '../game/engine'; +import { leaders, spiceDeck, treacheryDeck } from '../game/cards'; +import { NEXUS_FACTIONS } from '../game/nexus-cards'; +import { DISCOVERY_SPICE_CARDS, DISCOVERY_TOKENS } from '../game/discoveries'; +import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards'; +import { createOccupiedPercentageState } from '../game/homeworld-occupied-percentage'; +import { + createMixedPhaseModulesFixture, mixedPhaseModulesInventory, type MixedPhaseModulesOptions, +} from './fixture-mixed-phase-modules'; + +function originalOccupation(game: Game) { + assert.equal(game.homeworldOccupationPreview, true); + assert.equal(game.pendingHomeworldOccupiedIncome, null); + assert.deepEqual(game.homeworldOccupiedIncomeHistory, []); + assert.ok(game.homeworldOccupiedPercentageLedger); + assert.deepEqual(game.homeworldOccupiedPercentageLedger.receipts, []); + if (game.players.some(player => player.faction === 'ecaz')) { + assert.equal(game.homeworldEcazVictoryPreview, true); assert.equal(game.ecazOccupyPreview, true); + } +} +function originalModules(game: Game, options: MixedPhaseModulesOptions) { + if (options.nexus) { + const cards = game.nexusCards?.cards; assert.ok(cards); + assert.deepEqual([...cards.deck, ...cards.discard, ...Object.values(cards.hands).filter(card => card !== null)].sort(), + [...NEXUS_FACTIONS].sort()); + } else assert.ok(!game.nexusCards); + if (options.discoveries) { + assert.equal(game.discoveryEnabled, true); assert.ok(game.discoveries); + assert.deepEqual(game.discoveries.tokens.map(token => token.face).sort(), DISCOVERY_TOKENS.map(token => token.id).sort()); + assert.equal(new Set(game.discoveries.tokens.map(token => token.id)).size, 8); + const cards = [...game.spiceDeck, ...game.spiceDiscard.flat(), + ...(game.spiceResolution?.skipped ?? []), ...(game.spiceSequence?.skipped ?? [])]; + assert.equal(cards.length, spiceDeck(game.expansions.includes('ix')).length + 7); + assert.deepEqual(cards.flatMap(card => 'territory' in card && card.discovery ? [card.discovery] : []).sort(), + DISCOVERY_SPICE_CARDS.map(card => card.discovery).sort()); + assert.equal(cards.filter(card => 'worm' in card && card.greatMaker).length, 1); + } else assert.ok(!game.discoveryEnabled && !game.discoveries); + if (options.skills) { + const skills = game.leaderSkills; assert.ok(skills); + assert.deepEqual([...skills.deck, ...Object.values(skills.offers).flatMap(offer => offer.cards), + ...skills.assignments.map(assignment => assignment.skill)].sort(), LEADER_SKILL_CARDS.map(card => card.id).sort()); + } else assert.ok(!game.leaderSkills); +} +function entry(options: MixedPhaseModulesOptions) { + const f = createMixedPhaseModulesFixture({ ...options, homeworlds: true }); + assert.equal(f.setup.status, 'setup'); originalOccupation(f.setup); + assert.equal(f.game.status, 'playing'); assert.equal(f.game.turn, 1); assert.equal(f.game.phase, 1); + originalModules(f.setup, options); originalModules(f.game, options); + assert.equal(f.nexus, !!options.nexus); assert.equal(f.discoveries, !!options.discoveries); + assert.equal(f.originalInventory.length, treacheryDeck(options.expansions).length); + assert.deepEqual(mixedPhaseModulesInventory(f.game), f.originalInventory); + const firstReady = f.actions.findIndex(step => step.action.type === 'ready'); + for (const [enabled, module] of [[options.nexus, 'nexusCards'], [options.discoveries, 'discoveries']] as const) { + if (!enabled) continue; + const selected = f.actions.findIndex(step => step.action.type === 'moduleOption' && step.action.module === module && step.action.enabled === true); + assert.ok(selected >= 0 && selected < firstReady); assert.equal(f.actions[selected].actor, f.initial.host); + } + for (const player of f.afterSetup.players) { + assert.deepEqual(player.leaders.map(leader => leader.id).sort(), leaders(player.faction).map(leader => leader.id).sort()); + assert.ok(viewGame(f.game, player.id).homeworlds?.worlds?.some(world => world.native === player.id)); + const requested = options.training?.[player.faction]; + if (requested) { + const assignment = f.afterSetup.leaderSkills!.assignments.find(assignment => assignment.owner === player.id); + assert.ok(assignment); assert.equal(assignment.skill, requested); + assert.ok(player.leaders.some(leader => leader.id === assignment.leader)); + assert.ok(f.actions.some(step => step.actor === player.id && step.action.type === 'leaderSkill' && + step.action.skill === requested && step.action.leader === assignment.leader)); + } + } + assert.ok(!f.game.semutaPreview && !f.game.ecazTreachery); + assert.deepEqual(f.staging, []); assert.equal(f.sourceActionCount, f.actions.length); + return f; +} + +// These recipes exercise previously registered original World envelopes. They +// do not assert new roster or overlay admission. +void test('public original classic World Nexus and Discovery starts retain ordinary occupation and physical components', () => { + entry({ factions: ['atreides', 'emperor', 'fremen'], expansions: [], nexus: true, tech: true }); + entry({ factions: ['atreides', 'emperor'], expansions: [], discoveries: true }); +}); + +void test('public original Advanced World Nexus plus Discovery keeps real fourteen-card training and phase modules', () => { + entry({ factions: ['atreides', 'emperor', 'fremen'], expansions: [], advanced: true, + nexus: true, discoveries: true, skills: true, tech: true, strongholds: true, + training: { atreides: 'warmaster', emperor: 'mentat', fremen: 'swordmaster-of-ginaz' } }); +}); + +void test('public admitted paired E1 Nexus and standalone Ecaz Discovery retain original Worlds and occupation state', () => { + entry({ factions: ['ixians', 'tleilaxu', 'guild'], expansions: ['ix'], nexus: true, tech: true }); + entry({ factions: ['ecaz', 'emperor', 'guild'], expansions: ['ecaz'], discoveries: true }); +}); + +void test('an original World Nexus lobby rejects a pre-existing occupation ledger without overwriting it', () => { + const f = createMixedPhaseModulesFixture({ factions: ['atreides', 'emperor', 'fremen'], expansions: [], + homeworlds: true, nexus: true }); + // Controlled negative input only: this otherwise valid original ready lobby + // has an existing typed ledger before fresh setup is allowed to create one. + const game = structuredClone(f.lobby); + game.homeworldOccupiedPercentageLedger = createOccupiedPercentageState(); + const before = structuredClone(game); + assert.throws(() => applyAction(game, game.host, { type: 'start' })); + assert.deepEqual(game, before); +});