Connect Homeworld occupation with original phase modules

This commit is contained in:
Quintine committed 2026-10-26 11:43:16 +11:00
1 parent 0a98a92f4b
commit 7f9daa10fa
19 files changed
+796 -40

No files matched your search

+32
View File
@@ -16,6 +16,38 @@ open mode gates.
## Current checkpoint and work
**Homeworld occupation / original phase modules — 26 October. Prototyped / Partial.**
Clean registered classic/no-deck and selected-family Worlds now compose optional
independent cards, Tech3–6 Basic/Advanced and Strongholds2–6 Advanced without
incidental Skills/Nexus. Fresh starts include original occupation qualification,
Collection/percentage ledgers, native Tupile and Ecaz World victory beside native
locations. Mandatory Ecaz/Moritani handlers compose; legacy module contexts and
existing games remain separate.
Three disjoint profile/native/control lanes and root integration pass **16 files,
115/115**, types/lint/build and **six native runtime paths**,18 GameTable SSR
renders. Bare mixed Basic uses21 actions/card47; classic/no-deck uses10/card33.
All-six Basic World/Tech and Advanced World/cards/Tech/Strongholds use250/267
actions/card57/60, actual native sources, Storm loss/free return, delayed token
income and earned cards. Four minimal policies each submit legal next actions.
Actual A/Ecaz/Ix all3 World/card50 sources use57/62 actions: Atreides ships10
Caladan reserves to Arrakeen for10, then Ecaz ships1 to empty Caladan for1.
Accepted sole arrival creates the original qualification. Twenty continuation
actions keep unallied occupied Caladan bank2 separate from Heighliners end-phase1,
Advanced ordinary Arrakeen Collection2 and first END-Mentat Arrakeen/HMS cards.
No force, wallet, qualification, payout, phase or ownership is installed.
Feedback corrects the Advanced ordinary-income expectation, nullable/type
narrowing, and related old Revival fixtures: real current-owner completion,
Basic-only reopening, original lobby circles and existing High-Ix bonus2 retain
paid/free/Banker/phase-end ledgers. Frozen report/manifest:
`/home/quintine/.local/share/dune/checkpoints/20261026-homeworld-phase-proof-v3/`;
journeys/HTML: adjacent `20261026-homeworld-phase-runtime/`.
No saved-room/D1/server, reset/restart, deployment or separate assurance campaign.
SSR/public-action proof leaves browser/mobile/deployed, wider combinations and
complete-mode acceptance open. [Source and contract](HOMEWORLD_RULES.md#clean-homeworlds-and-phase-modules--26-october-2026).
**Selected-deck independent cards / phase modules — 26 October. Prototyped / Partial.**
Clean independent cards now compose registered paired/mixed/all-three selected
families and classic-only unused decks with optional Tech3–6 Basic/Advanced and
+6
View File
@@ -4,6 +4,12 @@ Audit: 2026-09-06. Scope: Reinforcements, Recruits, and Harass & Withdraw. This
## Selected independent cards and phase modules — 26 October 2026
The later [clean World contract](HOMEWORLD_RULES.md#clean-homeworlds-and-phase-modules--26-october-2026)
additionally admits registered bare mixed E3 Worlds with optional independent
cards/phase modules and their mandatory occupation state. It supersedes the
dated plain-World roster exclusions in this document; other registered modules
and complete-mode gates remain separate.
The printed independent-variant rule (E3 pp9,11 above), together with supplied
Advanced physical21/printed19 variant independence, now composes clean selected
decks across registered paired/mixed/all-three native rosters and classic-only
+58
View File
@@ -2,6 +2,64 @@
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.
## Clean Homeworlds and phase modules — 26 October 2026
Fresh clean Homeworld starts now use registered classic or selected-family
rosters, including paired/mixed/all-three native families and unused decks,
without incidental Skills, Nexus or independent-card requirements. Every native
needs its family selected; classic empty-deck selection is valid. Optional
independent cards retain36/38/50 and any separate Richese cache. Optional Tech
requires3–6 Basic/Advanced seats; Strongholds requires2–6 Advanced, or3–6 beside
Tech. Skills/Nexus/Discovery/Sandtrout and explicit Semuta keep separate profiles.
The existing genuine occupation entry supplies the shared fresh state: original
qualification history, occupied Collection/percentage ledgers, native CHOAM
Tupile leases and native Ecaz World-victory marker. Normal clean World and
independent-card World starts now initialize those printed benefits as well as
native locations. Original advanced Moritani assassination and Arrakis Ecaz
coalition/arrival rules compose the same public profile. Existing games receive
no added stamp, ownership assignment, module retrofit or reset.
Authority remains the audited E3 pp9–10/15–16 and physical occupied faces below,
with the user-authorized Advanced physical21/printed19 variant independence and
existing Advanced qualification cutover. No new income amount, expiry policy,
occupation share, tariff or victory interpretation is introduced. Original
Tupile, auction-controller, percentage and Duke contracts remain. Tech accrues
and pays at its matching phase; held Stronghold cards are earned only at actual
end-Mentat. A World is not a seventh Stronghold Card or ordinary Arrakis site.
Public-profile, native phase/control and native occupation lanes plus root
integration pass16 files,115/115, types/lint/build and six native runtime paths,
18 GameTable SSR renders. Bare mixed Basic starts in21 actions/card47 and classic
no-deck in10/card33. All-six Basic World/Tech and Advanced World/independent/Tech/
Strongholds use250/267 actions/card57/60, with actual native source allocations,
matching delayed income, native free return and earned cards. Four minimal
policies each submit legal next actions from both phase-five boundaries.
Actual A/Ecaz/Ix all3-deck World/independent50 sources use57 Basic /62 Advanced
actions. Atreides pays10 to ship its10 native Caladan reserves to already-held
Arrakeen; Ecaz pays1 to ship1 original native reserve to empty Caladan. The
accepted sole arrival creates the real qualification and printed bank2
entitlement. Twenty continuation actions complete actual End Movement, pay
Heighliners1 once, credit the unallied Caladan2 automatically, and reach the
actual first END-Mentat claims. Advanced ordinary Arrakeen Collection2 remains
separate; no seventh card or early held ownership appears. No played force,
wallet, phase, qualifier, receipt or ownership is installed; no controlled
positioning supplements are needed for this proof.
Feedback corrects the Advanced ordinary-income expectation and test type
narrowing. Related old World/Recruits sources now finish actual preceding
Advanced owners, retain the current owner's opportunity across requests, keep
Basic ready/reopen and choose original legal lobby circles for later actors.
Existing High-Ix paid-Cyborg bonus2 moves real additional Suboids; payments,
ordinary/free limits, Banker and delayed Axlotl remain distinct. An explicit
legacy occupation-Skills entry still cannot inherit this clean phase scope.
Report/manifest: `/home/quintine/.local/share/dune/checkpoints/20261026-homeworld-phase-proof-v3/`;
journeys/HTML: adjacent `20261026-homeworld-phase-runtime/`.
No saved-room/D1/server/reset/restart/deployment or separate assurance campaign.
This is Prototyped / Partial; complete combinations, browser/mobile/deployed
and release acceptance remain open.
## High Richese mixed shipment prototype — 25 October 2026
**Prototyped / Partial.** Native Richese at High may select actual paid reserve
+29
View File
@@ -1,5 +1,34 @@
# Dune implementation status
## 26 October 2026 — Clean Homeworld occupation and phase modules
**Prototyped / Partial.** Clean registered classic/
selected-family Worlds with optional independent cards, Tech3–6 and Advanced
Strongholds2–6 now initialize original occupation benefits beside physical
locations. Shared fresh state retains qualification, Collection/percentage,
native Tupile and Ecaz World victory. No incidental Skills/Nexus, played-game
retrofit or new numeric policy; legacy module contexts retain their scope.
Disjoint profile/phase/control/native source lanes and root integration pass
**16 files,115/115**, types/lint/build and **six native runtime paths**,18
GameTable SSR renders. Bare mixed Basic21/card47 and classic10/card33 starts
retain original setup; all-six Basic250/card57 and Advanced267/card60 programmes
retain native sources, Storm loss/free return, delayed token income and claims.
Four minimal policies each submit legal next actions. A/Ecaz/Ix actual sources
use57/62 actions/card50: paid Atreides10 empties Caladan, Ecaz1/fee1 earns sole
qualification, and20 continuations keep occupied bank2, Heighliners phase-end1,
Advanced ordinary Arrakeen2 and first Mentat cards separate. No played-state
install or controlled positioning claim is required.
Feedback corrects Advanced normal Collection and test typing, then repairs old
ordered Revival/Basic reopen/source-circle and existing High-Ix bonus2 fixture
assumptions without changing physical fees, quotas, Banker or delayed income.
Report/manifest: `/home/quintine/.local/share/dune/checkpoints/20261026-homeworld-phase-proof-v3/`;
journeys/HTML: adjacent `20261026-homeworld-phase-runtime/`. No saved-room/D1,
server/reset/restart/deployment or separate assurance campaign. Broader
combinations, browser/mobile/deployed and complete-mode acceptance remain open.
[Current source and scope](HOMEWORLD_RULES.md#clean-homeworlds-and-phase-modules--26-october-2026).
## 26 October 2026 — Selected independent cards and phase modules
**Prototyped / Partial.** Clean selected-deck
+1
View File
@@ -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) |
| 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) |
| Independent Treachery / original phase modules | Printed independence retains original Tech3–6 Basic/Advanced and/or Strongholds2–6 Advanced with classic/E1/E2 and original standalone/paired E3 bare cards; all14 also composes registered mixed selected families and Worlds. Real first-Storm/end-Mentat owners, separate delayed income and physical funding remain. Fees/H are not revival/shipment and +2 is not supported force. Native coalition/assassination and original queue stay source-labelled. Bare mixed E3 World/phase, original Nexus/Discovery/Semuta and role/effect guards remain. | [Original phase contract](ECAZ_TREACHERY_RULES.md#original-phase-module-composition--16-october-2026), [registered scope](LEADER_SKILLS_RUNTIME.md#registered-native-e3-selected-decks--18-october-2026) |
| Classic World independent cards / phase modules | Compose original unused Tech3+ and/or Advanced Strongholds2+ with the existing classic36-card Homeworld entry. Preserve native typed custody, current separate-request rates/paid ledgers, exact source-bearing R fees, original face/support, fee/dial disjointness and fee-before-Harass. Keep every own-World Harass ban; native returns are not shipment/free Revival. Original delayed income and earned cards add no personal subsidy or supported +2. This inherits existing provisional timing, not a new publisher ruling or native expansion/public gate. | [Source-bound contract](ECAZ_TREACHERY_RULES.md#classic-world-phase-module-composition--16-october-2026) |
+33 -14
View File
@@ -17,6 +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 { 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';
@@ -9836,12 +9837,12 @@ function moritaniAssassinateModeSupported(g: Game) {
(f.expansion === 'base' || g.expansions.includes(f.expansion)))) &&
(!g.nexusCards || (!!g.leaderSkills && standaloneE3NexusLeaderSkillsProfile(g))) &&
(!g.leaderSkills || advancedMoritaniLeaderSkillsProfile(g)) &&
(!g.strongholdCards || selectedFactionStrongholdProfile(g) || e3StrongholdFactionProfile(g) || independentCardModulesProfile(g) ||
(!g.strongholdCards || homeworldPhaseModulesProfile(g) || selectedFactionStrongholdProfile(g) || e3StrongholdFactionProfile(g) || independentCardModulesProfile(g) ||
(!!g.leaderSkills && strongholdLeaderSkillsProfile(g))) &&
(!g.homeworlds || g.homeworldOccupationPreview === true || ecazHomeworldProfile(g) ||
(!!g.leaderSkills && homeworldLeaderSkillsProfile(g))) &&
!g.discoveryEnabled && !g.discoveries &&
(!g.techTokens || nativeFactionTechProfile(g) || independentCardModulesProfile(g) ||
(!g.techTokens || homeworldPhaseModulesProfile(g) || nativeFactionTechProfile(g) || independentCardModulesProfile(g) ||
(!!g.leaderSkills && nativeTechLeaderSkillsProfile(g)));
}
function moritaniAssassinateContext(g: Game, receipt: MoritaniAssassinateReceipt) {
@@ -10836,26 +10837,34 @@ export function initializeHomeworldGameForAudit(state: Game): Game {
!!state.homeworlds,
'Enable the Homeworld module in the audit lobby first.',
);
if (homeworldPhaseModulesProfile(state)) return initializeHomeworldOccupationGameForAudit(state);
return initializeSetupGameForAudit(state, true);
}
/** Fresh occupation prototype; ambiguity remains source-gated rather than assigned a controller. */
export function initializeHomeworldOccupationGameForAudit(state: Game, leaderSkills = false): Game {
requireRule(state.homeworlds?.custody === null && !state.homeworldOccupationPreview &&
!state.homeworldOccupationHistory && !state.nexusCards && !state.leaderSkills &&
!state.techTokens && !state.strongholdCards && !state.discoveryEnabled &&
((!state.techTokens && !state.strongholdCards) || (!leaderSkills && homeworldPhaseModulesProfile(state))) && !state.discoveryEnabled &&
!state.discoveries && !state.discoveryStash && !state.greatMaker && !state.ecazTreachery &&
typeof state.advanced === 'boolean' && state.expansions.length <= 3 &&
state.expansions.every((id, index) => (id === 'ix' || id === 'choam' || id === 'ecaz') &&
state.expansions.indexOf(id) === index) &&
state.players.every(p => FACTIONS.some(f => f.id === p.faction &&
(f.expansion === 'base' || state.expansions.includes(f.expansion)))),
'Occupation requires a fresh Homeworld lobby with original selected faction decks and no other modules.');
'Occupation requires a fresh Homeworld lobby with original selected faction decks and supported original phase modules.');
requireFreshBaseRuntime(state);
requireFreshFactionInventory(state);
requireRule(!leaderSkills || !state.expansions.length && homeworldLeaderSkillsProfile(state),
'Occupation with Leader Skills requires original classic Homeworlds without other modules.');
const g = leaderSkills ? initializeLeaderSkillsGameForAudit(state)
: initializeSetupGameForAudit(state, true, false, false, false, false, false, true);
initializeHomeworldOccupationState(g);
if (moritaniAssassinateModeSupported(g)) initializeMoritaniAssassinateState(g);
return g;
}
/** Original occupation benefits belong to fresh World setup, beside its native
* custody/history; phase modules do not replace those printed benefits. */
function initializeHomeworldOccupationState(g: Game): void {
g.homeworldOccupationPreview = true;
g.pendingHomeworldOccupiedIncome = null;
g.homeworldOccupiedIncomeHistory = [];
@@ -10871,8 +10880,6 @@ export function initializeHomeworldOccupationGameForAudit(state: Game, leaderSki
g.homeworldEcazVictoryPreview = true;
g.ecazOccupyPreview = true;
}
if (moritaniAssassinateModeSupported(g)) initializeMoritaniAssassinateState(g);
return g;
}
/** Shared classic/supported-native Nexus setup; existing composition guards apply. */
export function initializeNexusGameForAudit(state: Game): Game {
@@ -11849,6 +11856,7 @@ function initializeFactionExpansionsGame(state: Game, semuta = false): Game {
return initialized;
}
function ecazOccupyCompositionSupported(g: Game): boolean {
if (homeworldPhaseModulesProfile(g) && byFaction(g, 'ecaz')) return true;
// Native Basic uses the same physical coalition and selected decks.
// Family/roster checks below remain authoritative; optional configurations
// retain their separate registered profiles rather than inheriting admission.
@@ -12105,13 +12113,15 @@ function independentCardRoster(g: Game): boolean {
return family === 'base' || g.expansions.includes(family);
});
}
/** Bare card World/phase rosters keep their E3 envelope; Skills uses the shared registered capability. */
/** Legacy World/card envelope; clean registered profiles are admitted before
* this fallback, while other module contexts keep their original scope. */
function independentCardModuleRoster(g: Game): boolean {
return independentCardRoster(g) &&
(!g.players.some(player => player.faction === 'ecaz' || player.faction === 'moritani') ||
(g.expansions.length === 1 && g.expansions[0] === 'ecaz'));
}
function ecazHomeworldProfile(g: Game): boolean {
if (homeworldPhaseModulesProfile(g)) return true;
if (g.homeworlds && (independentNexusProfile(g) || independentDiscoveryProfile(g))) return true;
if (g.homeworlds && g.leaderSkills && !g.sandtrout && independentTreacheryLeaderSkillsProfile(g)) return true;
return !!g.homeworlds && g.ecazTreachery === true && independentCardModuleRoster(g) &&
@@ -12128,6 +12138,7 @@ function ecazHomeworldProfile(g: Game): boolean {
}
/** Independent cards compose original phase modules, not unrelated previews. */
function independentCardModulesProfile(g: Game): boolean {
if ((g.techTokens || g.strongholdCards) && g.ecazTreachery === true && homeworldPhaseModulesProfile(g)) return true;
if ((g.techTokens || g.strongholdCards) && selectedIndependentCardsProfile(g)) return true;
if ((g.techTokens || g.strongholdCards) && (independentNexusProfile(g) || independentDiscoveryProfile(g))) return true;
if ((g.techTokens || g.strongholdCards) && g.leaderSkills && !g.sandtrout &&
@@ -12160,7 +12171,12 @@ export function initializeEcazTreacheryGameForAudit(state: Game): Game {
const homeworlds = !!g.homeworlds;
requireRule(!homeworlds || ecazHomeworldProfile(g),
'Independent World cards need a supported selected-deck roster with original phase modules and supported Nexus selections.');
if (homeworlds && homeworldPhaseModulesProfile(g)) {
requireFreshBaseRuntime(g);
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;
@@ -12188,10 +12204,11 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
mixedE3NexusLeaderSkillsProfile(g) || singleE1E2NexusLeaderSkillsProfile(g) || mixedE1E2NexusLeaderSkillsProfile(g) ||
pairedE3NexusLeaderSkillsProfile(g) || standaloneE3NexusLeaderSkillsProfile(g))));
const independentModules = ecazTreachery && independentCardModulesProfile(g);
requireRule(!g.techTokens || ((skillTech || nativeTech || nexusModules || discoveryComposition || independentModules) &&
const worldPhaseModules = homeworlds && homeworldPhaseModulesProfile(g);
requireRule(!g.techTokens || ((worldPhaseModules || skillTech || nativeTech || nexusModules || discoveryComposition || independentModules) &&
JSON.stringify(g.techTokens) === JSON.stringify(createTechTokens())),
'Tech Tokens require a fresh supported three-through-six-seat lobby with unused tokens.');
requireRule(!g.strongholdCards || strongholdFactions || ((skillStronghold || homeworldSkills || nexusModules || discoveryComposition || independentModules) &&
requireRule(!g.strongholdCards || strongholdFactions || ((worldPhaseModules || skillStronghold || homeworldSkills || nexusModules || discoveryComposition || independentModules) &&
JSON.stringify(g.strongholdCards) === JSON.stringify(createStrongholdCards())),
'Stronghold Cards require a fresh supported Advanced lobby with unused cards.');
requireRule(
@@ -12202,8 +12219,8 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
(discovery || !g.discoveryEnabled) &&
(nexus || !g.nexusCards) &&
(ecazTreachery || !g.ecazTreachery) &&
(!g.techTokens || skillTech || nativeTech || nexusModules || discoveryComposition || independentModules) &&
(strongholdFactions || skillStronghold || homeworldSkills || nexusModules || discoveryComposition || independentModules || !g.strongholdCards) &&
(!g.techTokens || worldPhaseModules || skillTech || nativeTech || nexusModules || discoveryComposition || independentModules) &&
(worldPhaseModules || strongholdFactions || skillStronghold || homeworldSkills || nexusModules || discoveryComposition || independentModules || !g.strongholdCards) &&
(homeworlds || !g.homeworlds) &&
g.players.every((p) =>
FACTIONS.some(
@@ -18648,6 +18665,7 @@ function ambassadorEffectBlock(
return null;
}
function arrivalOverlapModeSupported(g: Game, revivalArrival?: RevivalEntryParent): boolean {
if (homeworldPhaseModulesProfile(g) && byFaction(g, 'ecaz') && byFaction(g, 'moritani')) return true;
if (revivalArrival && g.homeworlds && byFaction(g, 'ecaz') && byFaction(g, 'moritani')) return true;
if (bareBasicSemutaProfile(g) && byFaction(g, 'ecaz') && byFaction(g, 'moritani')) return true;
if ((independentNexusProfile(g) || independentDiscoveryProfile(g)) && byFaction(g, 'ecaz') && byFaction(g, 'moritani')) return true;
@@ -24203,9 +24221,10 @@ function homeworldOccupiedIncomeIntegrity(g: Game) {
'Occupied income requires its original fresh occupation profile.');
return;
}
requireRule(g.homeworldOccupationPreview === true && g.homeworlds && !g.nexusCards &&
(!g.leaderSkills || !g.expansions.length && homeworldLeaderSkillsProfile(g)) &&
!g.techTokens && !g.strongholdCards && !g.discoveryEnabled &&
requireRule(g.homeworldOccupationPreview === true && g.homeworlds &&
(homeworldPhaseModulesProfile(g) || (!g.nexusCards &&
(!g.leaderSkills || !g.expansions.length && homeworldLeaderSkillsProfile(g)) &&
!g.techTokens && !g.strongholdCards && !g.discoveryEnabled)) &&
Array.isArray(history) && pending !== undefined,
'The fresh occupation profile lost its original Homeworld module or income ledger.');
const turns = new Set<number>();
+50
View File
@@ -0,0 +1,50 @@
import { FACTIONS } from './catalog';
import type { FactionModuleProfile } from './faction-module-profile';
export type HomeworldPhaseModulesProfile = FactionModuleProfile & {
homeworldOccupationPreview?: unknown;
homeworldTupilePreview?: unknown;
homeworldEcazVictoryPreview?: unknown;
};
/** A real empty selection means classic decks. Do not emulate that case by
* adding a selected family that is absent from the actual configuration. */
function registeredRoster(game: HomeworldPhaseModulesProfile): boolean {
if (!Array.isArray(game.expansions) || game.expansions.length > 3 ||
!game.expansions.every(family => family === 'ix' || family === 'choam' || family === 'ecaz') ||
new Set(game.expansions).size !== game.expansions.length ||
!Array.isArray(game.players) || game.players.length < 2 || game.players.length > 6) return false;
const seated = new Set<string>();
for (const player of game.players) {
if (!player || typeof player !== 'object' || Array.isArray(player) ||
typeof player.faction !== 'string' || seated.has(player.faction)) return false;
const faction = FACTIONS.find(faction => faction.id === player.faction);
if (!faction || faction.expansion !== 'base' && !game.expansions.includes(faction.expansion)) return false;
seated.add(player.faction);
}
return true;
}
const optionalFlag = (value: unknown) => value === undefined || typeof value === 'boolean';
/** Configuration permission only: original setup validates freshness, and live
* custody/history validators retain their own physical and occupation rules.
* Optional independent cards do not require either phase module. */
export function homeworldPhaseModulesProfile(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.leaderSkills || game.nexusCards || game.discoveryEnabled || game.discoveries ||
game.discoveryStash || game.greatMaker || game.sandtrout || game.spiceBankerIncomePreview ||
game.semutaPreview || game.advancedPreview || game.kullPreview || game.nexusKullPreview ||
game.guildBetrayalPreview || game.richeseBetrayalPreview || game.nexusIxianReplacementPreview ||
game.nexusIxianBetrayalPreview || game.nexusHarkonnenBetrayalPreview) return false;
if (![game.ecazOccupyPreview, game.moritaniAssassinatePreview, game.homeworldOccupationPreview,
game.homeworldTupilePreview, game.homeworldEcazVictoryPreview].every(optionalFlag)) return false;
const ecaz = game.players.some(player => player.faction === 'ecaz');
const moritani = game.players.some(player => player.faction === 'moritani');
const choam = game.players.some(player => player.faction === 'choam');
return (!game.ecazOccupyPreview || ecaz) &&
(!game.moritaniAssassinatePreview || game.advanced && moritani) &&
(!game.homeworldTupilePreview || game.homeworldOccupationPreview === true && choam) &&
(!game.homeworldEcazVictoryPreview || game.homeworldOccupationPreview === true && ecaz);
}
+6 -4
View File
@@ -3901,7 +3901,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Classic Advanced Nexus core also retains original Strongholds2–6 and optional Tech3–6 with base33 or selected independent36, optionally all14. Earn cards only at actual end-Mentat. Earned Arrakeen composes temporary-Sardaukar support and real Reinforcements reserve fees without making bank money personal; earned Carthag still requires a physical effective defense after an original Harkonnen call is canceled. Original cleanup, token reward, held custody and private Mentat replacement remain.',
'Fresh Advanced all14 Skills uses registered actual seats and selected ordinary decks with original Strongholds2–6 and optional Tech3–6, including its independent-card/World envelope. Native E3 needs no Nexus or exact single family merely to use Skills. Original Nexus/Discovery/Semuta card contexts and Advanced role guards remain separate. Actual end-Mentat claims stay. Arrakeen funds only local holder-plan support, never native World support, personal cash or reserve fees; normal Banker uses the final payer debit. Rescue, cleanup, Tech and Face Dance retain their order.',
'Ecaz/Moritani Skills may include other registered selected-family seats and unused selected ordinary decks, with original Worlds/phase modules and optional independent cards. Native five-disc training and original role exclusions remain. Normal Warmaster one on a temporary Duke never grants trained three. Winner Suk precedes unused-disc assassination, bounty, winner Tech and one Mentat replacement. Ecaz-held support belongs only to the selected Ecaz plan, not an allied plan; fixed and variable armies remain separate. Advanced E3/Harkonnen, shared Duke and effect-specific guards stay.',
'Fresh independent Treachery previews retain clean registered selected-deck paired/mixed/all-three native rosters and classic-only unused decks, with optional Tech3–6 and Advanced Strongholds2–6. Basic mixed Ecaz also admits bare cards. Original end-Mentat custody, physical reserve fees, owner-only Harass returns and selected-winner Tech remain. Bare mixed E3 Worlds and separate Skills/Nexus/Discovery/Semuta profiles retain their own admission.',
'Fresh independent Treachery previews retain clean registered selected-deck paired/mixed/all-three native rosters and classic-only unused decks, with optional Tech3–6 and Advanced Strongholds2–6. Basic mixed Ecaz also admits bare cards. Original end-Mentat custody, physical reserve fees, owner-only Harass returns and selected-winner Tech remain. Clean World cards use their registered occupation profile; Skills/Nexus/Discovery/Semuta retain separate admission.',
'The six effects, public ownership and pre-plan choice are integrated in development games. Enable Stronghold Cards in an Advanced preview lobby. Complete Advanced compliance and every expansion interaction remain under verification.',
],
example:
@@ -4001,7 +4001,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Owning all three tokens counts as one additional stronghold at victory checks. An alliance cannot combine separate token holdings to qualify: one member must own the complete set. Multiple sides reaching the victory target can share the win.',
'Token ownership and pending income are public and persist. Fresh registered selected-family all14 keeps original Tech3–6 Basic/Advanced and optional Advanced Strongholds2–6, including supported Worlds and independent cards, without an incidental Nexus or single-family E3 requirement. Original Nexus/Discovery/Semuta and role/effect guards remain. Rescue/board-spice precedes winner Tech and Face Dance. Mandatory normal Banker waits for Mentat, separate from phase-end Tech; fees/H returns are not revival/shipment. Native CHOAM opening income is not Charity. Two-player Tech and full combined acceptance remain unfinished.',
'Without other optional overlays, fresh normal selected-deck starts retain original Tech3–6 Basic/Advanced with registered paired/mixed/all-three native families or classic-only seats and unused decks. Advanced Strongholds2–6 optionally compose. Native payments, typed casualties, selected Ecaz lead rewards and existing captured-leader Moritani assassination remain separate; split allied holdings never form a complete set. Explicit Semuta keeps its earlier scope. Existing provisional Basic odd Occupy, Richese rulings and unrelated profiles are not newly decided; played games are not converted.',
'Clean independent-card selected-deck rosters include paired/mixed/all-three natives and classic-only unused families with optional Tech3–6 and Advanced Strongholds2–6. Recruits changes current native rates without repeating Axlotl or advancing payout. Harass deposits and reserve fees are not free Revival or off-planet shipment. Original phase income, mandatory coalition/assassination and winner rewards remain. Bare mixed E3 Worlds and unrelated module/role profiles keep separate admission.',
'Clean independent-card selected-deck rosters include paired/mixed/all-three natives and classic-only unused families with optional Tech3–6 and Advanced Strongholds2–6. Recruits changes current native rates without repeating Axlotl or advancing payout. Harass deposits and reserve fees are not free Revival or off-planet shipment. Original phase income, mandatory coalition/assassination and winner rewards remain. Clean World cards retain registered occupation and original phase modules; unrelated module/role profiles keep separate admission.',
'Classic Nexus core composes original Tech3–6 Basic/Advanced and optional Advanced Strongholds2–6 with base33 or selected independent36, optionally all14. Real first-Storm owners and actual token counts set income. A paid fixed Tleilaxu force group does not become free because Recruits is active; subsequent genuine free returns accrue one Axlotl pile, not one pile per request. Pending spice cannot fund Revival before phase end. Harass returns and reserve fees are not token-triggering Revival or shipment; actual battle winners take one original opposing token.',
],
example:
@@ -4813,9 +4813,10 @@ export const RULE_TOPICS: RuleTopic[] = [
).join(' '),
steps: [
'Homeworlds are an optional module from Ecaz & Moritani, available under the official rules with Basic or Advanced play. They are separate locations outside Arrakis; they do not receive ordinary storm movement or count as ordinary strongholds.',
'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.',
'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. Bare native E3 World cards retain their original roster limits. 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.',
'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.',
'Low population normally grants one additional free force revival and one additional bank spice when collecting CHOAM Charity. Salusa grants neither bonus; CHOAM’s Tupile reverses the usual pattern by granting an advantage while low and a penalty while high.',
'A qualifying charity claim separates its ordinary payer from the extra bank spice. Inflation doubles both portions or prevents the whole collection. Canceling Bene Gesserit charity prevents both portions of that claim.',
@@ -4871,6 +4872,7 @@ export const RULE_TOPICS: RuleTopic[] = [
detail:
"All 13 cards, saved typed custody, supported-deck setup, Emperor movement, native Arrakis shipment sources, revival destinations, low charity/free-revival benefits, Tleilax’s phase-start income penalty and high-Salusa free Sardaukar support are connected. World-to-world invasion, Guild transport from Arrakis and Guild native returns use exact source pools, funding and interception. Homeworld battles connect native bonuses, limited explosion casualties, native-only Traitors and Face Dancers, and Ixian substitution. Junction offers connect sponsored board and Homeworld routes, including own returns, foreign departures and their distinct payments. Caladan foresight, Wallach advisor counts, Ix stronghold movement and Richese token movement now respect native population. High Tupile blocks Worthless sales and Ambassador payouts, including after an interrupted Ghola revival. Kaitain paid discards share the closing Bidding opportunity with CHOAM; Ecaz poison income uses actual physical disposal and current native population. Mandatory winning-card disposal follows winner casualties. Supported low Kaitain and Junction payment receipts preserve the payer’s cost and use current native population; divergent allied rounding remains guarded. Giedi grants its once-per-phase bonus on actual positive desert receipts, including settled Ecaz shares. Low Grumman uses original public entry counts across Terror and competing-reaction checks. Richese High now connects actual paid reserve companions, one combined provisional invoice, original marker proof/responses, physical withdrawal and later reserve-capped reveal. Nexus Cunning retains that explicit paid group in its pair receipt, reserving paid counters before immediate materialization; original counter and spent-card outcomes remain. Ix High now returns physical Suboids for paid Cyborgs with provisional additional allowance and shortage handling, retaining typed free allocation, original invoice and free-event income. Southern and Tleilax preserve the exact eligible revival group through one optional placement. Provisional physical entry now opens native BG, Terror and Ambassador responses, retaining the original held income, independent counters and ordered overlap; a committed Sneak group has its own arrival source. Southern placement provisionally retains the ordinary storm and HMS entry route; Tleilax retains the Ixian-only direct off-planet restriction. Fresh occupation additionally connects bank and percentage receipts, completed Southern Collection, immediate sharing, Caladan inspection, displaced Ix pool, Richese card-only choice with native terms, real Giedi receiver choice and native protections. Fresh native CHOAM entries additionally connect original Tupile slots, actual normal-limit cleanup and restored low-query authority without resetting usage. Advanced retains sole-qualified ownership until last departure. Basic Tupile provisionally follows current sole control: observed departure, contest or native high population ends its slot, while a new activation waits for earlier normal-limit cleanup. Continuous turns do not stack slots or reset intelligence use. Exceptional Duke, concealed transport and full/public acceptance remain unfinished. Already purchased ordinary Karama now composes with immune Junction through explicit unused-price acknowledgment; original tariff/payees stay unchanged and only delivered transport consumes its grant.",
evidence: [
'game/homeworld-module-profile.ts',
'game/faction-module-profile.ts',
'game/reinforcements-homeworld.ts',
'components/reinforcements-sources.tsx',
@@ -5006,7 +5008,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/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-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',
+128
View File
@@ -0,0 +1,128 @@
import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
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 {
createMixedPhaseModulesFixture, actMixedPhaseModules, mixedPhaseModulesClean,
mixedPhaseModulesInventory, mixedPhaseModulesPlayer, nextMixedPhaseModules,
type MixedPhaseModulesStep, type MixedPhaseModulesTransition,
} from './fixture-mixed-phase-modules';
export type HomeworldPhaseOccupationStep = MixedPhaseModulesStep;
export type HomeworldPhaseOccupationTransition = MixedPhaseModulesTransition;
export const actHomeworldPhaseOccupation = actMixedPhaseModules;
export const homeworldPhaseOccupationClean = mixedPhaseModulesClean;
export const homeworldPhaseOccupationInventory = mixedPhaseModulesInventory;
export const homeworldPhaseOccupationPlayer = mixedPhaseModulesPlayer;
/** Optional Ambassador placement is declined; all source/return/payment/phase
* choices remain their actual native decisions. No income chooser is invented
* for an unallied occupied-bank award, which settles automatically. */
export function nextHomeworldPhaseOccupation(game: Game): HomeworldPhaseOccupationStep | null {
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard && game.decision?.kind === 'ecazPlacement')
return { actor: game.decision.player, action: { type: 'decision', decline: true } };
return nextMixedPhaseModules(game);
}
export function advanceHomeworldPhaseOccupation(game: Game, until: (state: Game) => boolean,
actions: HomeworldPhaseOccupationStep[] = []): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original occupied-income source ended before its next native control');
const step = nextHomeworldPhaseOccupation(game);
assert.ok(step, `Choose the actual native control at ${game.turn}/${game.phase}/${game.decision?.kind}`);
game = actHomeworldPhaseOccupation(game, step.actor, step.action, actions);
}
throw Error('The original occupied-income source did not reach its finite boundary');
}
export function finishHomeworldPhaseOccupation(game: Game, actions: HomeworldPhaseOccupationStep[] = []): Game {
return advanceHomeworldPhaseOccupation(game, state => state.turn === 2 && state.phase === 0 && homeworldPhaseOccupationClean(state), actions);
}
/** Public Atreides/Ecaz/Ixians +all3 selected decks, independent cards, Worlds,
* Tech and optional Advanced Strongholds. Atreides actually spends its native10
* to ship all10 Caladan normal counters into already-held Arrakeen; Ecaz then
* pays1 for an original normal counter from its own World into empty Caladan.
* The accepted arrival records sole occupation. Only the shared source's
* original scalar and unopened Spice lotteries are controlled. No card, army,
* wallet, World owner, qualification, income ledger or clock is installed.
* Stop at the real final Movement actor before automatic Collection credit. */
export function createHomeworldPhaseOccupationFixture(options: { advanced?: boolean } = {}) {
const advanced = options.advanced ?? false, native = 'atreides', owner = 'ecaz', techOwner = 'ixians';
const world = 'homeworld:atreides', card = 'caladan' as const;
const source = createMixedPhaseModulesFixture({ factions: [native, owner, techOwner],
expansions: ['ix', 'choam', 'ecaz'], advanced, homeworlds: true,
tech: true, strongholds: advanced, independent: true });
let game = source.game;
const actions = [...source.actions];
const act = (actor: string, action: Action) => { game = actHomeworldPhaseOccupation(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceHomeworldPhaseOccupation(game, until, actions); };
const transition = (run: () => void): HomeworldPhaseOccupationTransition => {
const before = structuredClone(game), from = actions.length; run();
return { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
};
assert.ok(game.homeworldOccupationPreview && game.homeworldOccupationHistory);
assert.equal(game.homeworldOccupationHistory.qualifications.length, 0);
assert.equal(game.techTokens?.heighliners.owner, techOwner);
reach(state => state.phase === 5 && state.active === native && homeworldPhaseOccupationClean(state));
assert.equal(homeworldPhaseOccupationPlayer(game, native).reserves, 10);
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(homeworldPhaseOccupationClean); });
assert.equal(homeworldPhaseOccupationPlayer(game, native).reserves, 0);
assert.equal(homeworldPhaseOccupationPlayer(game, native).forces['arrakeen:10'], 20);
assert.equal(homeworldPhaseOccupationPlayer(evacuation.before, native).spice - homeworldPhaseOccupationPlayer(game, native).spice, 10);
assert.equal(quoteStableHomeworldOccupation(game, card).entitlement, null);
reach(state => state.phase === 5 && state.active === owner && homeworldPhaseOccupationClean(state));
const incomingSources = nativeShipmentSources(viewGame(game, owner), 1, 0); assert.ok(incomingSources);
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(homeworldPhaseOccupationClean); });
assert.equal(homeworldPhaseOccupationPlayer(invasion.before, owner).spice - homeworldPhaseOccupationPlayer(game, owner).spice, 1);
assert.deepEqual(game.homeworlds?.custody?.visitors[world]?.[owner], { normal: 1, elite: 0 });
const qualification = game.homeworldOccupationHistory!.qualifications.find(q => q.world === world && q.player === owner);
assert.ok(qualification, 'The actual accepted sole arrival records 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);
homeworldGameIntegrity(game);
reach(state => state.phase === 5 && state.active === techOwner && homeworldPhaseOccupationClean(state));
assert.equal(game.order.at(-1), techOwner);
assert.equal(game.techTokens?.heighliners.spice, 1);
assert.deepEqual(homeworldPhaseOccupationInventory(game), source.originalInventory);
return { ...source, game, sourceGame: structuredClone(game), advanced, native, owner, techOwner,
world, card, current: techOwner, action: { type: 'endMovement' } as Action,
evacuationSources, evacuationAction, evacuation, incomingSources, invasionQuote, invasionAction, invasion,
qualification: structuredClone(qualification), entitlement: structuredClone(stable.entitlement),
actions, sourceActions: structuredClone(actions), sourceActionCount: actions.length,
staging: [] as string[] };
}
export type HomeworldPhaseOccupationFixture = ReturnType<typeof createHomeworldPhaseOccupationFixture>;
/** The original final Movement action pays its pending Tech first; original
* Collection then automatically credits the unallied occupier's printed2.
* Actual END Mentat later claims the six separate Stronghold cards. */
export function completeHomeworldPhaseOccupation(f: HomeworldPhaseOccupationFixture, state = f.game) {
let game = state;
const actions: HomeworldPhaseOccupationStep[] = [];
const act = (actor: string, action: Action) => { game = actHomeworldPhaseOccupation(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceHomeworldPhaseOccupation(game, until, actions); };
const transition = (run: () => void): HomeworldPhaseOccupationTransition => {
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 && homeworldPhaseOccupationClean(state)); });
const history = game.homeworldOccupiedIncomeHistory?.find(receipt => receipt.turn === game.turn &&
receipt.queue.some(row => row.world === f.world)); assert.ok(history);
const incomeReceipt = history.receipts.find(receipt => receipt.world === f.world); assert.ok(incomeReceipt);
const mentat = transition(() => { game = finishHomeworldPhaseOccupation(game, actions); });
homeworldGameIntegrity(game);
assert.deepEqual(homeworldPhaseOccupationInventory(game), f.originalInventory);
return { game, collection, incomeHistory: structuredClone(history), incomeReceipt: structuredClone(incomeReceipt),
mentat, actions, actionCount: actions.length };
}
+17 -5
View File
@@ -1,7 +1,8 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { TERRITORIES, MOBILE_STRONGHOLD, location, territory } from '../game/board';
import type { FactionId } from '../game/catalog';
import { withNativeShipmentSources } from '../game/homeworld-options';
import { actAdvisorPreparation } from './fixture-advisor-preparation';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
@@ -14,6 +15,7 @@ export type MixedPhaseModulesOptions = {
tech?: boolean;
strongholds?: boolean;
independent?: boolean;
homeworlds?: boolean;
};
export const MIXED_PHASE_NATIVE_FACTIONS = ['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani'] as const;
export const actMixedPhaseModules = actAdvisorPreparation;
@@ -75,6 +77,7 @@ export function createMixedPhaseModulesFixture(options: MixedPhaseModulesOptions
for (const faction of others) joinGame(game, newPlayer(faction, faction, faction));
const initial = structuredClone(game), actions: MixedPhaseModulesStep[] = [];
const act = (actor: string, action: Action) => { game = actMixedPhaseModules(game, actor, action, actions); };
if (options.homeworlds) act(host, { type: 'homeworlds', 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 });
@@ -98,6 +101,7 @@ export function createMixedPhaseModulesFixture(options: MixedPhaseModulesOptions
const firstStorm = structuredClone(game);
assert.deepEqual(mixedPhaseModulesInventory(game), originalInventory);
return { game, sourceGame: structuredClone(game), independent: options.independent ?? false,
homeworlds: options.homeworlds ?? false,
initial, lobby, setup, afterSetup, firstStorm,
originalInventory, originalSpice, spiceOrder, actions, sourceActions: structuredClone(actions),
sourceActionCount: actions.length, staging: [] as string[] };
@@ -107,13 +111,21 @@ export function createMixedPhaseModulesFixture(options: MixedPhaseModulesOptions
* accrue Heighliners once; native first Mentat claims precede a real later
* Storm casualty (Basic dials2 / Advanced card1) and that owner's free return. Sources remain ordinary public
* entry plus original native choices, with no played-state installation. */
export function createMixedPhaseModulesProgramme(options: { advanced?: boolean; strongholds?: boolean; independent?: boolean } = {}) {
export function createMixedPhaseModulesProgramme(options: {
advanced?: boolean; strongholds?: boolean; independent?: boolean; homeworlds?: 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 });
expansions: ['ix', 'choam', 'ecaz'], advanced, tech: true, strongholds,
independent: options.independent, homeworlds: options.homeworlds });
let game = source.game;
const actions = [...source.actions];
const act = (actor: string, action: Action) => { game = actMixedPhaseModules(game, actor, action, actions); };
const ship = (actor: string, action: Action) => {
const selected = game.homeworlds ? withNativeShipmentSources(viewGame(game, actor), action) : action;
assert.ok(selected, 'The current native World pools must fund the exact typed shipment');
act(actor, selected);
};
const reach = (until: (state: Game) => boolean) => { game = advanceMixedPhaseModules(game, until, actions); };
const transition = (run: () => void): MixedPhaseModulesTransition => {
const before = structuredClone(game), from = actions.length; run();
@@ -123,7 +135,7 @@ export function createMixedPhaseModulesProgramme(options: { advanced?: boolean;
const tabr = territory('sietch_tabr').sectors[0], choamLocation = location('sietch_tabr', tabr);
assert.ok(game.players.every(player => !player.forces[choamLocation]));
const choamShipment = transition(() => {
act('choam', { type: 'ship', territory: 'sietch_tabr', sector: tabr, amount: 1, elite: 0, allyPayment: 0 });
ship('choam', { type: 'ship', territory: 'sietch_tabr', sector: tabr, amount: 1, elite: 0, allyPayment: 0 });
reach(mixedPhaseModulesClean);
});
reach(state => state.turn === 1 && state.phase === 5 && state.active === 'moritani' && mixedPhaseModulesClean(state));
@@ -133,7 +145,7 @@ export function createMixedPhaseModulesProgramme(options: { advanced?: boolean;
assert.ok(target, 'The next original Storm sector has an empty desert shipment destination');
const casualtyLocation = location(target.id, sector);
const moritaniShipment = transition(() => {
act('moritani', { type: 'ship', territory: target.id, sector, amount: 1, elite: 0, allyPayment: 0 });
ship('moritani', { type: 'ship', territory: target.id, sector, amount: 1, elite: 0, allyPayment: 0 });
reach(mixedPhaseModulesClean);
});
// Capture the actual phase-closing action, rather than folding later income
+26 -1
View File
@@ -7,6 +7,7 @@ import { nativeShipmentSources } from '../game/homeworld-options';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import { forceRevivalQuote } from '../game/revival';
import { RECRUITS_CARD_ID } from '../game/recruits';
import { ixHomeworldRevivalEnabled, quoteIxSuboidBonus } from '../game/ix-homeworld-revival';
import type { IndependentCardModulesOptions } from './fixture-independent-card-modules';
import { createIndependentCardModulesFixture } from './fixture-independent-card-modules';
import { nextHarassHomeworldStep } from './fixture-harass-homeworld';
@@ -47,6 +48,8 @@ export const nativeWorldRecruitsClean = (game: Game) =>
* An unrequested Face Dancer is declined; every other window keeps its original
* continuation and the whole remaining programme is driven by it. */
export function nextNativeWorldRecruitsStep(game: Game): NativeWorldRecruitsStep | null {
if (game.phase === 4 && nativeWorldRecruitsClean(game) && !game.pendingRevival && game.revivalOrder?.remaining[0])
return { actor: game.revivalOrder.remaining[0], action: { type: 'ready' } };
if (!game.response && !game.phaseOpening && game.decision?.kind === 'faceDance')
return { actor: game.decision.player, action: { type: 'decision', reveal: false } };
return nextHarassHomeworldStep(game);
@@ -182,6 +185,7 @@ export function createNativeWorldRecruitsFixture(options: {
assert.equal(nativeWorldRecruitsPlayer(game, native).tanks, native === 'ixians' ? 14 : 20);
game = advanceNativeWorldRecruits(game,
g => g.turn === 2 && g.phase === 4 && nativeWorldRecruitsClean(g), steps);
game = awaitNativeWorldRecruitsActor(game, options.imperialCasualties ? visitor : native, steps);
takeHarassCard(game, owner, RECRUITS_CARD_ID);
staging.push(`Conserve the existing physical Recruits to ${owner} at the real second Revival; this is not a purchase.`);
assertNativeWorldRecruitsInventory(game);
@@ -190,13 +194,34 @@ export function createNativeWorldRecruitsFixture(options: {
location, steps, staging };
}
/** Complete only preceding original owners. A finished opportunity cannot be
* reopened; Basic and an unstamped legacy phase retain concurrent timing. */
export function awaitNativeWorldRecruitsActor(game: Game, actor: string,
preparation: NativeWorldRecruitsStep[] = []): Game {
while (game.revivalOrder && game.revivalOrder.remaining[0] !== actor) {
assert.ok(game.revivalOrder.remaining.includes(actor), 'The requested native Revival opportunity must still be pending.');
const preceding = game.revivalOrder.remaining[0]; assert.ok(preceding);
const step: NativeWorldRecruitsStep = { actor: preceding, action: { type: 'ready' } };
preparation.push(step); game = applyAction(game, step.actor, step.action);
game = advanceNativeWorldRecruits(game, state => state.phase === 4 && nativeWorldRecruitsClean(state), preparation);
}
return game;
}
/** Capture the quote, accepted request, response/deployment choices and physical
* deposit. Keep invoice price separate from Tleilaxu's own free-return income. */
export function requestNativeWorldRecruits(game: Game, actor: string, amount: number, elite = 0,
freeElite?: number) {
const preparation: NativeWorldRecruitsStep[] = [];
game = awaitNativeWorldRecruitsActor(game, actor, preparation);
const before = structuredClone(game);
const player = nativeWorldRecruitsPlayer(before, actor);
const quote = forceRevivalQuote(before, player, amount, elite, freeElite);
const ixSuboidBonus = player.faction === 'ixians' ? quoteIxSuboidBonus({
enabled: ixHomeworldRevivalEnabled(before, actor) && !before.revivalRules?.freeBlocked?.includes(actor),
amount, elite, free: quote.free, freeElite: freeElite ?? Math.min(quote.free, elite),
normalTanks: player.tanks - (player.elites?.tanks ?? 0),
}) : 0;
const step: NativeWorldRecruitsStep = { actor,
action: { type: 'revive', amount, elite, ...(freeElite === undefined ? {} : { freeElite }) } };
const nativeBefore = combatArmy(before, actor, `homeworld:${player.faction}`);
@@ -204,7 +229,7 @@ export function requestNativeWorldRecruits(game: Game, actor: string, amount: nu
const continuations: NativeWorldRecruitsStep[] = [];
game = advanceNativeWorldRecruits(requested, nativeWorldRecruitsClean, continuations);
const after = nativeWorldRecruitsPlayer(game, actor);
return { before, requested, game, step, continuations, quote,
return { before, requested, game, step, preparation, continuations, quote, ixSuboidBonus,
rate: viewGame(before, actor).revival,
price: quote.cost, walletDelta: player.spice - after.spice,
free: (after.freeForcesRevived ?? 0) - (player.freeForcesRevived ?? 0),
+24 -3
View File
@@ -60,6 +60,16 @@ export function createWorldPhaseRecruitsFixture(options: WorldPhaseRecruitsOptio
let lobby = createGame('WORLDPHASERECRUITS', newPlayer(emperor, 'Emperor', 'emperor'), advanced);
for (const [id, faction] of [[fremen, 'fremen'], [atreides, 'atreides'], [guild, 'guild']] as const)
joinGame(lobby, newPlayer(id, faction, faction));
const lobbySteps: RecruitsHomeworldStep[] = [];
if (advanced) {
// First Storm0 ends at1; the native later card1..6 ends at2..7.
// Actual printed circles14/17 therefore keep Atreides before Fremen,
// without completing the latter before its later typed requests.
for (const [actor, position] of [[atreides, 5], [fremen, 6]] as const) {
const action: Action = { type: 'seatPosition', position };
lobby = applyAction(lobby, actor, action); lobbySteps.push({ actor, action });
}
}
lobby = applyAction(lobby, emperor, { type: 'homeworlds', enabled: true });
lobby.techTokens = createTechTokens();
if (strongholds) lobby.strongholdCards = createStrongholdCards();
@@ -96,6 +106,13 @@ export function createWorldPhaseRecruitsFixture(options: WorldPhaseRecruitsOptio
}
throw Error('Original World revival decisions did not settle');
};
const awaitOwner = (actor: string) => {
while (game.revivalOrder && game.revivalOrder.remaining[0] !== actor) {
assert.ok(game.revivalOrder.remaining.includes(actor), 'The later native request must retain its pending opportunity.');
const preceding = game.revivalOrder.remaining[0]; assert.ok(preceding);
act(preceding, { type: 'ready' }); settle();
}
};
const actions = {
paidReturn: { type: 'revive', amount: 3, elite: 0 },
readyAtreides: { type: 'ready' },
@@ -112,13 +129,16 @@ export function createWorldPhaseRecruitsFixture(options: WorldPhaseRecruitsOptio
atreides: structuredClone(viewGame(game, atreides).revival),
};
const wallets = Object.fromEntries(game.players.map(p => [p.id, p.spice]));
awaitOwner(atreides);
act(atreides, actions.paidReturn); settle();
const afterPaidReturn = structuredClone(game);
act(atreides, actions.readyAtreides);
if (!advanced) act(atreides, actions.readyAtreides);
const beforePlay = structuredClone(game);
act(emperor, actions.play);
const afterPlay = structuredClone(game);
act(atreides, actions.reopenedReturn); settle();
if (advanced) act(atreides, actions.readyAtreides);
awaitOwner(fremen);
const beforeFremen = structuredClone(game);
act(fremen, actions.typedReturn); settle();
const afterTypedReturn = structuredClone(game);
@@ -149,11 +169,12 @@ export function createWorldPhaseRecruitsFixture(options: WorldPhaseRecruitsOptio
doubled: { fremenFree: 7, atreidesFree: 4, limit: 7 },
afterThreshold: { freeRemaining: 5, forcesRemaining: 5, homeworldBonus: 0 },
},
nativeSteps: source.nativeSteps, steps,
nativeSteps: [...lobbySteps, ...source.nativeSteps], steps,
staging: [...source.staging,
'Select unused original Tech Tokens and optional Advanced Strongholds in the undealt lobby, before readiness and original setup.',
'Fix only the original first-Storm unused-token shuffle; restore native later Storm entropy. Custody and held-token count are read, never assigned.',
'Atreides pays for three original native returns before Recruits; its recorded paid counter remains paid after reopening. Fremen then returns one typed Fedaykin and five normal counters on separate current-rate requests.',
...(advanced ? ['Select original lobby circles14 for Atreides and17 for Fremen. Finish preceding native turns; keep Atreides current through both requests before advancing to still-pending Fremen. No played queue or phase is changed.'] : []),
'Atreides pays for three original native returns before Recruits; its recorded paid counter remains paid after activation. Basic retains ready/reopening; Advanced keeps that opportunity open. Fremen then returns one typed Fedaykin and five normal counters on separate current-rate requests.',
],
};
}
+101
View File
@@ -0,0 +1,101 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
homeworldPhaseModulesProfile as supported, type HomeworldPhaseModulesProfile,
} from '../game/homeworld-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> = {}): HomeworldPhaseModulesProfile {
return { advanced: false, expansions, players: factions.map(faction => ({ faction })),
homeworlds: { custody: null }, ...extra };
}
void test('classic empty-deck Homeworld starts permit both modes and independent cards without requiring phase modules', () => {
for (const advanced of [false, true]) for (const ecazTreachery of [undefined, false, true])
assert.equal(supported(profile(undefined, undefined, { advanced, ecazTreachery })), true);
assert.equal(supported(profile(undefined, undefined, { homeworldOccupationPreview: true })), true);
assert.equal(supported(profile(undefined, undefined, { homeworlds: null })), false);
assert.equal(supported(profile(undefined, undefined, { advanced: 'advanced' as unknown as boolean })), false);
});
void test('selected native, paired, mixed and unused family decks retain their actual Homeworld roster', () => {
for (const game of [
profile(['ecaz', 'moritani', 'guild'], ['ecaz']),
profile(['ecaz', 'ixians', 'emperor'], ['ix', 'ecaz']),
profile(['moritani', 'harkonnen', 'guild'], ['ecaz'], { advanced: true }),
profile(['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani'], ['ix', 'choam', 'ecaz'], { advanced: true }),
profile(['atreides', 'emperor', 'guild'], ['ix', 'choam', 'ecaz']),
profile(['ixians', 'guild', 'emperor'], ['ix', 'choam', 'ecaz']),
]) assert.equal(supported(game), true);
});
void test('Homeworld Tech retains three-six seats and Strongholds retain Advanced two-six with both requiring three', () => {
const two = profile(['atreides', 'emperor']);
assert.equal(supported(two), true);
assert.equal(supported({ ...two, techTokens: createTechTokens() }), false);
assert.equal(supported({ ...two, strongholdCards: createStrongholdCards() }), false);
assert.equal(supported({ ...two, advanced: true, strongholdCards: createStrongholdCards() }), true);
assert.equal(supported({ ...two, advanced: true, strongholdCards: createStrongholdCards(), techTokens: createTechTokens() }), false);
assert.equal(supported(profile(undefined, undefined, { ecazTreachery: true, techTokens: createTechTokens() })), true);
assert.equal(supported(profile(undefined, undefined, { advanced: true, ecazTreachery: true,
techTokens: createTechTokens(), strongholdCards: createStrongholdCards() })), true);
});
void test('unknown, duplicate, missing-family and out-of-bounds rosters do not create a fictitious selected deck', () => {
for (const game of [
profile(['ecaz', 'guild', 'emperor']), profile(['ixians', 'guild', 'emperor'], ['choam']),
profile(undefined, ['ix', 'ix']), profile(undefined, ['unknown']),
profile(undefined, ['ix', 'choam', 'ecaz', 'ix']), profile(['unknown', 'guild']),
profile(['atreides', 'atreides']), profile(['atreides']),
profile(['atreides', 'emperor', 'guild', 'fremen', 'beneGesserit', 'harkonnen', 'ecaz'], ['ecaz']),
]) assert.equal(supported(game), false);
for (const value of [null, {}, { ...profile(), expansions: 'ix' },
{ ...profile(), players: 'atreides,emperor' }, { ...profile(), players: [{ faction: 'atreides' }, null] }])
assert.equal(supported(value as unknown as HomeworldPhaseModulesProfile), false);
});
void test('independent-card selection is an exact optional boolean rather than a truthy capability', () => {
for (const ecazTreachery of [undefined, false, true]) assert.equal(supported(profile(undefined, undefined, { ecazTreachery })), true);
for (const ecazTreachery of [null, 0, 1, 'true', {}]) assert.equal(supported(profile(undefined, undefined, { ecazTreachery })), false);
});
void test('Skills, Nexus, Discovery, active Sandtrout and unrelated reaction selectors remain outside the clean World scope', () => {
for (const field of [
'leaderSkills', 'nexusCards', 'discoveryEnabled', 'discoveries', 'discoveryStash', 'greatMaker', 'sandtrout',
'spiceBankerIncomePreview', 'semutaPreview', 'advancedPreview', 'kullPreview', 'nexusKullPreview',
'guildBetrayalPreview', 'richeseBetrayalPreview', 'nexusIxianReplacementPreview', 'nexusIxianBetrayalPreview',
'nexusHarkonnenBetrayalPreview',
] as const) assert.equal(supported({ ...profile(), [field]: true }), false, field);
});
void test('canonical occupation and native benefit stamps retain their actual seated source and required parent', () => {
const native = profile(['ecaz', 'moritani', 'choam'], ['choam', 'ecaz'], { advanced: true,
ecazOccupyPreview: true, moritaniAssassinatePreview: true, homeworldOccupationPreview: true,
homeworldTupilePreview: true, homeworldEcazVictoryPreview: true });
assert.equal(supported(native), true);
assert.equal(supported(profile(['ecaz', 'guild'], ['ecaz'], { ecazOccupyPreview: true })), true);
for (const game of [
profile(undefined, undefined, { ecazOccupyPreview: true }),
profile(undefined, undefined, { advanced: true, moritaniAssassinatePreview: true }),
{ ...native, advanced: false },
profile(undefined, undefined, { homeworldOccupationPreview: true, homeworldTupilePreview: true }),
profile(undefined, undefined, { homeworldOccupationPreview: true, homeworldEcazVictoryPreview: true }),
{ ...native, homeworldOccupationPreview: undefined },
]) assert.equal(supported(game), false);
for (const field of ['ecazOccupyPreview', 'moritaniAssassinatePreview', 'homeworldOccupationPreview',
'homeworldTupilePreview', 'homeworldEcazVictoryPreview'] as const)
assert.equal(supported({ ...native, [field]: 'forged' }), false, field);
});
void test('live occupied custody is configuration-neutral and the predicate never clones, resets or repairs physical state', () => {
const custody = { visitors: { 'homeworld:atreides': { emperor: { normal: 1, elite: 1 } } },
salusa: { normal: 0, elite: 4 } };
const game = profile(undefined, undefined, { advanced: true, homeworlds: { custody, historyVersion: 1 },
homeworldOccupationPreview: true, ecazTreachery: true, techTokens: createTechTokens(), strongholdCards: createStrongholdCards() });
const before = structuredClone(game);
Object.freeze(custody); Object.freeze(game.homeworlds); 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);
});
@@ -0,0 +1,50 @@
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();
// Original public actions drive setup. React SSR supplies visible-control
// evidence without claiming that these stateful components received DOM clicks.
void test('World and phase module controls retain their checked state and actual Basic/Advanced Start paths', () => {
for (const advanced of [false, true]) {
const f = createMixedPhaseModulesFixture({ factions: ['ixians', 'ecaz', 'moritani'], expansions: ['ix', 'ecaz'],
advanced, homeworlds: true, tech: true, strongholds: advanced, independent: advanced });
const render = (game: typeof f.game) => renderToStaticMarkup(createElement(GameTable, {
game: viewGame(game, game.host), busy: false, send: async () => {}, onExit() {},
}));
const html = render(f.lobby);
const labels = [...html.matchAll(/<label\b[^>]*>([\s\S]*?)<\/label>/g)];
for (const text of ['Homeworlds · preview', 'Tech tokens · optional', ...(advanced ? ['Stronghold Cards', 'Ecaz Treachery Cards'] : [])]) {
const label = labels.find(([, children]) => children.includes(text)); assert.ok(label, text);
assert.match(label[1], /<input\b[^>]*checked=""/);
}
const text = advanced ? 'Begin Advanced preview' : 'Begin game';
const button = [...html.matchAll(/<button\b([^>]*)>([\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.actor === f.initial.host && step.action.type === 'homeworlds' && step.action.enabled === true));
assert.ok(f.actions.some(step => step.action.type === 'start' && !!step.action.advancedPreview === advanced));
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.homeworldOccupationPreview, true);
const playing = render(f.game);
assert.match(playing, /Heighliners/); assert.match(playing, /Homeworld|Grumman|Tleilax/);
if (advanced) assert.match(playing, /Inspect Stronghold Cards/);
}
});
@@ -0,0 +1,127 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { initializeHomeworldOccupationGameForAudit, viewGame, type Game } from '../game/engine';
import { MOBILE_STRONGHOLD } from '../game/board';
import { treacheryDeck } from '../game/cards';
import { nativeShipmentSources } from '../game/homeworld-options';
import { forceRevivalQuote } from '../game/revival';
import { ownedTech } from '../game/tech-tokens';
import {
createMixedPhaseModulesFixture, createMixedPhaseModulesProgramme,
mixedPhaseModulesClean, mixedPhaseModulesInventory, mixedPhaseModulesPlayer as player,
type MixedPhaseModulesOptions,
} from './fixture-mixed-phase-modules';
function freshWorlds(game: Game) {
assert.ok(game.homeworlds); assert.equal(game.homeworlds.custody, null);
assert.equal(game.homeworldOccupationPreview, true);
assert.equal(game.pendingHomeworldOccupiedIncome, null);
assert.deepEqual(game.homeworldOccupiedIncomeHistory, []);
assert.ok(game.homeworldOccupiedPercentageLedger);
if (game.players.some(seat => seat.faction === 'choam')) {
assert.equal(game.homeworldTupilePreview, true); assert.ok(game.homeworldTupileState);
assert.equal(game.pendingHomeworldTupileCleanup, null);
}
if (game.players.some(seat => seat.faction === 'ecaz')) {
assert.equal(game.homeworldEcazVictoryPreview, true); assert.equal(game.ecazOccupyPreview, true);
}
}
/** Source/final component facts only, without a save/custody campaign. Native
* World pools are the physical reserve locations, rather than extra counters. */
function components(game: Game) {
const worlds = viewGame(game, game.host).homeworlds?.worlds; assert.ok(worlds?.length);
for (const seat of game.players) {
const native: number = worlds.filter(world => world.native === seat.id)
.reduce((total, world) => total + world.forces[seat.id].normal + world.forces[seat.id].elite, 0);
const visitors: number = worlds.filter(world => world.native !== seat.id)
.reduce((total, world) => total + (world.forces[seat.id]?.normal ?? 0) + (world.forces[seat.id]?.elite ?? 0), 0);
assert.equal(native, seat.reserves);
assert.equal(seat.reserves + seat.tanks + visitors + Object.values(seat.forces).reduce((a, b) => a + b, 0), 20);
}
}
void test('the explicit legacy occupation Skills entry does not inherit clean World phase-module admission', () => {
const f = createMixedPhaseModulesFixture({ factions: ['atreides', 'emperor', 'guild'], expansions: [],
advanced: true, homeworlds: true, tech: true });
const before = structuredClone(f.lobby);
assert.throws(() => initializeHomeworldOccupationGameForAudit(f.lobby, true), /Occupation requires/);
assert.deepEqual(f.lobby, before);
});
void test('public bare Basic Worlds start mixed selected families and classic no-deck seats with original occupation state', () => {
const entries: MixedPhaseModulesOptions[] = [
{ factions: ['ecaz', 'ixians', 'tleilaxu'], expansions: ['ix', 'choam', 'ecaz'] },
{ factions: ['atreides', 'emperor'], expansions: [] },
];
for (const selected of entries) {
const f = createMixedPhaseModulesFixture({ ...selected, homeworlds: true });
assert.equal(f.initial.homeworlds, undefined); assert.deepEqual(f.lobby.homeworlds, { custody: null });
const option = f.actions.findIndex(step => step.action.type === 'homeworlds' && step.action.enabled === true);
const ready = f.actions.findIndex(step => step.action.type === 'ready');
assert.ok(option >= 0 && option < ready); assert.equal(f.actions[option].actor, f.initial.host);
assert.equal(f.setup.status, 'setup'); freshWorlds(f.setup);
assert.equal(f.game.advanced, false); assert.equal(f.game.phase, 1); assert.equal(f.game.turn, 1);
assert.equal(f.homeworlds, true); assert.equal(f.independent, false);
assert.ok(!f.game.techTokens && !f.game.strongholdCards && !f.game.ecazTreachery &&
!f.game.leaderSkills && !f.game.nexusCards && !f.game.discoveryEnabled && !f.game.semutaPreview);
assert.deepEqual(f.game.expansions, selected.expansions);
assert.equal(f.originalInventory.length, treacheryDeck(selected.expansions).length);
assert.deepEqual(mixedPhaseModulesInventory(f.game), f.originalInventory);
components(f.afterSetup); assert.deepEqual(f.staging, []);
}
});
for (const advanced of [false, true]) void test(`public all-six ${advanced ? 'Advanced World independent cards, Tech and Strongholds' : 'Basic World Tech'} earns native shipments, revival and delayed payouts`, () => {
const f = createMixedPhaseModulesProgramme({ advanced, homeworlds: true, independent: advanced });
freshWorlds(f.setup); components(f.afterSetup);
assert.equal(f.setup.deck.length, advanced ? 50 : 47); assert.equal(f.setup.richeseCache?.length, 10);
assert.equal(f.originalInventory.length, advanced ? 60 : 57);
assert.equal(f.setup.ecazTreachery === true, advanced);
assert.ok(!f.game.leaderSkills && !f.game.nexusCards && !f.game.discoveryEnabled && !f.game.semutaPreview);
for (const [actor, receipt, key] of [
['choam', f.choamShipment, f.choamLocation], ['moritani', f.moritaniShipment, f.casualtyLocation],
] as const) {
const action = receipt.steps[0].action;
assert.equal(action.type, 'ship'); assert.equal(action.amount, 1); assert.equal(action.elite, 0);
assert.deepEqual(action.homeworldSources, nativeShipmentSources(viewGame(receipt.before, actor), 1, 0));
assert.equal(player(receipt.after, actor).reserves, player(receipt.before, actor).reserves - 1);
assert.equal(player(receipt.after, actor).forces[key], 1);
assert.equal(player(receipt.before, actor).spice - player(receipt.after, actor).spice, actor === 'choam' ? 1 : 2);
const earned = ownedTech(receipt.after.techTokens, 'ixians').length;
assert.equal(receipt.after.techTokens?.heighliners.spice, earned);
assert.equal(receipt.after.techTokens?.heighliners.triggeredTurn, 1);
assert.equal(player(receipt.after, 'ixians').spice, player(receipt.before, 'ixians').spice);
}
const shipmentIncome = f.shipmentPayout.before.techTokens!.heighliners.spice;
assert.ok(shipmentIncome > 0); assert.equal(f.shipmentPayout.after.techTokens?.heighliners.spice, 0);
assert.equal(player(f.shipmentPayout.after, 'ixians').spice - player(f.shipmentPayout.before, 'ixians').spice, shipmentIncome);
if (advanced) {
assert.equal(f.mentat.before.strongholdCards?.claimedTurn, 0);
assert.equal(f.mentat.after.strongholdCards?.claimedTurn, 1);
assert.equal(f.mentat.after.strongholdCards?.owners.carthag, 'moritani');
assert.equal(f.mentat.after.strongholdCards?.owners.sietch_tabr, 'choam');
assert.equal(f.mentat.after.strongholdCards?.owners[MOBILE_STRONGHOLD], 'ixians');
}
assert.equal(f.storm.before.stormMovementSource?.kind, advanced ? 'card' : 'dials');
assert.equal(f.storm.before.stormPending, advanced ? 1 : 2);
assert.equal(player(f.storm.after, 'moritani').tanks, 1);
assert.equal(player(f.storm.after, 'moritani').forces[f.casualtyLocation], undefined);
const view = viewGame(f.revival.before, 'moritani');
assert.ok(view.revival.freeRemaining >= 1); assert.equal(view.revival.tleilaxuHomeworldIncomeBlocked, false);
const quote = forceRevivalQuote(f.revival.before, player(f.revival.before, 'moritani'), 1, 0);
assert.equal(quote.free, 1); assert.equal(quote.cost, 0);
assert.deepEqual(f.revival.steps[0], { actor: 'moritani', action: { type: 'revive', amount: 1, elite: 0 } });
if (advanced) assert.equal(f.revival.before.revivalOrder?.remaining[0], 'moritani');
assert.equal(player(f.revival.after, 'moritani').tanks, 0);
assert.equal(player(f.revival.after, 'moritani').reserves - player(f.revival.before, 'moritani').reserves, 1);
assert.equal(player(f.revival.after, 'moritani').spice, player(f.revival.before, 'moritani').spice - quote.cost);
assert.equal(player(f.revival.after, 'tleilaxu').spice - player(f.revival.before, 'tleilaxu').spice, 1);
assert.equal(f.revival.after.techTokens?.axlotl.spice, ownedTech(f.revival.after.techTokens, 'tleilaxu').length);
const revivalIncome = f.revivalPayout.before.techTokens!.axlotl.spice;
assert.ok(revivalIncome > 0); assert.equal(f.revivalPayout.after.techTokens?.axlotl.spice, 0);
assert.equal(player(f.revivalPayout.after, 'tleilaxu').spice - player(f.revivalPayout.before, 'tleilaxu').spice, revivalIncome);
assert.equal(f.game.phase, 5); assert.equal(f.game.turn, 2); assert.ok(mixedPhaseModulesClean(f.game));
assert.deepEqual(mixedPhaseModulesInventory(f.game), f.originalInventory); components(f.game);
assert.deepEqual(f.staging, []);
});
@@ -0,0 +1,75 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { homeworldGameIntegrity } from '../game/homeworld-game';
import { TECH_TOKENS } from '../game/tech-tokens';
import {
completeHomeworldPhaseOccupation, createHomeworldPhaseOccupationFixture,
homeworldPhaseOccupationInventory as inventory, homeworldPhaseOccupationPlayer as player,
} from './fixture-homeworld-phase-occupation';
for (const advanced of [false, true]) {
void test(`public selected-family ${advanced ? 'Advanced Worlds Tech Strongholds' : 'Basic Worlds Tech'} pays actual sole Caladan occupation separately from phase rewards`, () => {
const f = createHomeworldPhaseOccupationFixture({ advanced });
assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup');
assert.deepEqual(f.setup.players.map(seat => seat.faction), ['atreides', 'ecaz', 'ixians']);
assert.deepEqual(f.setup.expansions, ['ix', 'choam', 'ecaz']);
assert.ok(f.setup.homeworlds && f.setup.ecazTreachery && f.setup.techTokens);
assert.equal(!!f.setup.strongholdCards, advanced);
assert.ok(f.game.homeworldOccupationPreview);
assert.ok(!f.game.nexusCards && !f.game.leaderSkills && !f.game.discoveryEnabled && !f.game.semutaPreview);
assert.equal(f.originalInventory.length, 50); assert.deepEqual(inventory(f.game), f.originalInventory);
assert.deepEqual(f.staging, []); assert.equal(f.sourceActionCount, f.sourceActions.length);
assert.equal(f.evacuation.steps[0].action.type, 'ship');
assert.equal(f.evacuation.steps[0].action.amount, 10);
assert.deepEqual(f.evacuationSources, { 'homeworld:atreides': { normal: 10, elite: 0 } });
assert.equal(player(f.evacuation.before, f.native).reserves, 10);
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.equal(f.invasionAction.type, 'homeworldShip'); assert.equal(f.invasionQuote.cost, 1);
assert.deepEqual(f.incomingSources, { 'homeworld:ecaz': { normal: 1, elite: 0 } });
assert.deepEqual(f.invasion.after.homeworlds?.custody?.visitors[f.world]?.[f.owner], { normal: 1, elite: 0 });
assert.equal(player(f.invasion.before, f.owner).reserves - player(f.invasion.after, f.owner).reserves, 1);
assert.equal(f.invasion.before.homeworldOccupationHistory?.qualifications.some(q => q.world === f.world), false);
assert.equal(f.qualification.world, f.world); assert.equal(f.qualification.player, f.owner);
assert.ok(f.invasion.after.homeworldOccupationHistory?.sources.some(source => source.event === f.qualification.event));
assert.equal(f.entitlement.qualification, f.qualification.event); assert.equal(f.entitlement.ally, null);
assert.equal(f.entitlement.spice, 2);
assert.equal(f.evacuation.after.techTokens?.heighliners.spice, 1);
assert.equal(f.invasion.after.techTokens?.heighliners.spice, 1, 'The second paid shipment does not duplicate the per-phase token accrual');
assert.equal(f.game.techTokens?.heighliners.owner, f.techOwner);
assert.equal(f.game.homeworldOccupiedIncomeHistory?.length, 0);
homeworldGameIntegrity(f.game);
const before = structuredClone(f.game), techBefore = structuredClone(f.game.techTokens);
const result = completeHomeworldPhaseOccupation(f);
assert.deepEqual(f.game, before, 'The qualified pre-Collection source remains reusable for the actual callback');
assert.deepEqual(result.collection.steps[0], { actor: f.current, action: f.action });
assert.equal(result.collection.before.phase, 5); assert.equal(result.collection.after.phase, 7);
assert.equal(player(result.collection.after, f.techOwner).spice - player(result.collection.before, f.techOwner).spice, 1);
assert.equal(result.collection.after.techTokens?.heighliners.spice, 0);
assert.equal(player(result.collection.after, f.owner).spice - player(result.collection.before, f.owner).spice, 2);
assert.equal(player(result.collection.after, f.native).spice,
player(result.collection.before, f.native).spice + (advanced ? 2 : 0),
'Advanced native Arrakeen Collection remains separate from the Ecaz occupier bank income');
assert.equal(result.collection.after.pendingHomeworldOccupiedIncome, null);
assert.equal(result.incomeHistory.queue.length, 1);
assert.equal(result.incomeHistory.queue[0].qualification, f.qualification.event);
assert.deepEqual(result.incomeReceipt, { world: f.world, status: 'settled', ownAmount: 2,
credits: [{ player: f.owner, amount: 2 }] });
for (const { id } of TECH_TOKENS)
assert.equal(result.game.techTokens![id].owner, techBefore![id].owner,
'Occupied-bank Collection transfers no original Tech custody');
assert.equal(result.game.turn, 2); assert.equal(result.game.phase, 0);
if (advanced) {
assert.equal(f.game.strongholdCards?.claimedTurn, 0);
assert.ok(Object.values(f.game.strongholdCards!.owners).every(owner => owner === null));
assert.equal(result.mentat.after.strongholdCards?.claimedTurn, 1);
assert.equal(result.mentat.after.strongholdCards?.owners.arrakeen, f.native);
assert.equal(result.mentat.after.strongholdCards?.owners.hidden_mobile_stronghold, f.techOwner);
}
assert.deepEqual(inventory(result.game), f.originalInventory); homeworldGameIntegrity(result.game);
assert.deepEqual(result.game.homeworlds?.custody?.visitors[f.world]?.[f.owner], { normal: 1, elite: 0 });
assert.equal(player(result.game, f.native).reserves, 0);
});
}
+15 -6
View File
@@ -97,11 +97,17 @@ for (const options of [
assert.equal(paid.free, 2);
assert.deepEqual(paid.nativeAfter, { normal: 2, elite: 1 });
programme.accrued(paid.game);
let game = applyAction(paid.game, native, { type: 'ready' });
assert.ok(game.ready.includes(native));
let game = paid.game;
if (!options.advanced) {
game = applyAction(game, native, { type: 'ready' });
assert.ok(game.ready.includes(native));
} else {
assert.equal(game.revivalOrder?.remaining[0], native);
assert.equal(game.revivalOrder?.started, true, 'Keep the original Advanced opportunity open for later requests');
}
const beforePlayWallets = wallets(game);
game = applyAction(game, f.owner, play);
assert.deepEqual(wallets(game), beforePlayWallets, 'Reopening never refunds the prior paid cyborg');
assert.deepEqual(wallets(game), beforePlayWallets, 'Recruits never refunds the prior paid cyborg');
assert.equal(player(game, native).revived, 3);
assert.equal(player(game, native).freeForcesRevived, 2);
assert.equal(viewGame(game, native).revival.freeRemaining, 2);
@@ -120,7 +126,9 @@ for (const options of [
assert.equal(last.price, 5);
assert.equal(last.walletDelta, 5);
assert.equal(last.free, 0);
assert.deepEqual(last.nativeAfter, { normal: 3, elite: 4 });
assert.equal(last.ixSuboidBonus, 2, 'Current High Ix grants two additional physical Suboids for the paid Cyborg');
assert.deepEqual(last.nativeAfter, { normal: 5, elite: 4 });
assert.equal(player(last.game, native).tanks, 5);
assert.equal(player(last.game, native).spice, player(f.game, native).spice - 8);
assert.equal(player(last.game, native).revived, 7);
assert.equal(player(last.game, native).freeForcesRevived, 4);
@@ -202,10 +210,11 @@ void test('Basic all14 native Ix: actual paid cyborgs accrue Banker separately f
const last = requestNativeWorldRecruits(crossing.game, native, 2, 1, 0);
assert.deepEqual({ cost: last.price, wallet: last.walletDelta, free: last.free },
{ cost: 5, wallet: 5, free: 0 });
assert.deepEqual(last.nativeAfter, { normal: 3, elite: 4 });
assert.equal(last.ixSuboidBonus, 2, 'The High bonus is physical stock, not another paid invoice or ordinary allowance');
assert.deepEqual(last.nativeAfter, { normal: 5, elite: 4 });
assert.equal(player(last.game, native).revived, 7);
assert.equal(player(last.game, native).freeForcesRevived, 4);
assert.equal(player(last.game, native).tanks, 7);
assert.equal(player(last.game, native).tanks, 5);
assert.equal(player(last.game, native).elites!.tanks, 0);
assert.equal(deferred(last.game), 1);
const receipt = last.game.spiceBankerIncome!.sources.at(-1)!;
+11 -6
View File
@@ -25,7 +25,7 @@ function continueToShipment(game: Game): Game {
for (const advanced of [false, true]) {
const mode = advanced ? 'Advanced' : 'Basic';
void test(`${mode} native Ix deposits paid cyborgs and free suboids, reopens with Recruits, then reprices after population five`, () => {
void test(`${mode} native Ix deposits paid cyborgs and free suboids, ${advanced ? 'keeps its opportunity open' : 'reopens'} with Recruits, then reprices after population five`, () => {
const fixture = createNativeWorldRecruitsFixture({ advanced, faction: 'ixians' });
const native = fixture.native;
assert.equal(nativeWorldRecruitsPlayer(fixture.game, native).spice, 10);
@@ -39,7 +39,9 @@ for (const advanced of [false, true]) {
assert.equal(paid.walletDelta, 3);
assert.equal(paid.free, 2);
assert.deepEqual(paid.nativeAfter, { normal: 2, elite: 1 });
let game = applyAction(paid.game, native, { type: 'ready' });
let game = paid.game;
if (!advanced) game = applyAction(game, native, { type: 'ready' });
else assert.equal(game.revivalOrder?.remaining[0], native);
game = applyAction(game, fixture.owner, play);
assert.equal(nativeWorldRecruitsPlayer(game, native).spice, 7, 'Activation never refunds the paid cyborg');
assert.equal(viewGame(game, native).revival.freeRemaining, 2);
@@ -56,11 +58,12 @@ for (const advanced of [false, true]) {
const last = requestNativeWorldRecruits(game, native, 2, 1, 0);
assert.equal(last.price, 5, 'One paid cyborg plus one paid suboid still costs five');
assert.equal(last.walletDelta, 5);
assert.deepEqual(last.nativeAfter, { normal: 3, elite: 4 });
assert.equal(last.ixSuboidBonus, 2, 'A paid Cyborg at current High returns two extra physical Suboids');
assert.deepEqual(last.nativeAfter, { normal: 5, elite: 4 });
assert.equal(nativeWorldRecruitsPlayer(last.game, native).freeForcesRevived, 4);
assert.equal(nativeWorldRecruitsPlayer(last.game, native).revived, 7);
assert.equal(nativeWorldRecruitsPlayer(last.game, native).spice, 2);
assert.equal(nativeWorldRecruitsPlayer(last.game, native).tanks, 7);
assert.equal(nativeWorldRecruitsPlayer(last.game, native).tanks, 5);
assert.equal(nativeWorldRecruitsPlayer(last.game, native).elites!.tanks, 0);
rejectsUnchanged(last.game, native, { type: 'revive', amount: 1, elite: 0 });
assert.equal(last.game.discard.filter(c => c.id === RECRUITS_CARD_ID).length, 1);
@@ -180,12 +183,14 @@ for (const advanced of [false, true]) {
continueToShipment(returned.game);
});
void test(`${mode} ordinary native Richese reopens a ready revival, receives six free plus one paid, and still stops at seven`, () => {
void test(`${mode} ordinary native Richese ${advanced ? 'keeps its opportunity open' : 'reopens a ready revival'}, receives six free plus one paid, and still stops at seven`, () => {
const fixture = createNativeWorldRecruitsFixture({ advanced, faction: 'richese' });
const spice = nativeWorldRecruitsPlayer(fixture.game, fixture.native).spice;
const first = requestNativeWorldRecruits(fixture.game, fixture.native, 3);
assert.equal(first.price, 0);
let game = applyAction(first.game, fixture.native, { type: 'ready' });
let game = first.game;
if (!advanced) game = applyAction(game, fixture.native, { type: 'ready' });
else assert.equal(game.revivalOrder?.remaining[0], fixture.native);
game = applyAction(game, fixture.owner, play);
assert.equal(viewGame(game, fixture.native).revival.freeRemaining, 3);
rejectsUnchanged(game, fixture.native, { type: 'revive', amount: 5, elite: 0 });
+7 -1
View File
@@ -43,7 +43,13 @@ for (const options of cases) {
assert.deepEqual({ revived: paid.revived, free: paid.freeForcesRevived, reserves: paid.reserves,
tanks: paid.tanks, spice: paid.spice },
{ revived: 3, free: 2, reserves: 13, tanks: 7, spice: player(start, atreides).spice - 2 });
assert.ok(f.beforePlay.ready.includes(atreides));
if (!options.advanced) assert.ok(f.beforePlay.ready.includes(atreides));
else {
assert.equal(f.beforePlay.revivalOrder?.remaining[0], atreides);
assert.equal(f.beforePlay.revivalOrder?.started, true);
assert.equal(f.beforePlay.ready.includes(atreides), false);
assert.equal(f.beforeFremen.revivalOrder?.remaining[0], fremen);
}
assert.equal(viewGame(f.afterPlay, fremen).revival.freeRemaining, 7);
assert.equal(viewGame(f.afterPlay, fremen).revival.limit, 7);
assert.equal(viewGame(f.afterPlay, atreides).revival.freeRemaining, 2,