diff --git a/components/suk-graduate.tsx b/components/suk-graduate.tsx
index a659c6f6..041bd262 100644
--- a/components/suk-graduate.tsx
+++ b/components/suk-graduate.tsx
@@ -2,15 +2,16 @@
import { useState } from 'react';
import type { Action, Decision } from '@/game/engine';
-import type { SukRescueOption } from '@/game/suk-graduate';
+import type { SukReserveDestinations, SukRescueOption } from '@/game/suk-graduate';
import { splitLocation } from '@/game/board';
import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
export function sukRescueLabel(option: SukRescueOption): string {
const total = option.normal + option.elite;
if (!total) return 'Do not rescue any forces';
const kept = option.kept
- ? `keep 1 ${option.kept.kind === 'normal' ? 'ordinary' : 'elite'} in sector ${splitLocation(option.kept.key).sector}; `
+ ? `keep 1 ${option.kept.kind === 'normal' ? 'ordinary' : 'elite'} ${option.kept.key.startsWith('homeworld:') ? 'in the original Homeworld pool' : `in sector ${splitLocation(option.kept.key).sector}`}; `
: '';
const origins = option.eliteReserves ? ` (cyborgs: ${Object.entries(option.eliteReserves)
.map(([key, count]) => `${count} from sector ${splitLocation(key).sector}`).join(', ')})` : '';
@@ -23,22 +24,58 @@ export function SukGraduatePanel({ decision, act, busy }: {
busy: boolean;
}) {
const [choice, setChoice] = useState(null);
+ const [secondaryAllocation, setSecondaryAllocation] = useState({ normal: 0, elite: 0 });
+ const option = choice === null ? null : decision.options[choice];
+ const returned = {
+ normal: (option?.normal ?? 0) - (option?.kept?.kind === 'normal' ? 1 : 0),
+ elite: (option?.elite ?? 0) - (option?.kept?.kind === 'elite' ? 1 : 0),
+ };
+ const primary = decision.reserveHomes?.find(home => !home.secondary);
+ const secondary = decision.reserveHomes?.find(home => home.secondary);
+ const allocate = !!primary && !!secondary && returned.normal + returned.elite > 0;
+ const allocationValid = !allocate || (['normal', 'elite'] as const).every(kind =>
+ Number.isSafeInteger(secondaryAllocation[kind]) && secondaryAllocation[kind] >= 0 &&
+ secondaryAllocation[kind] <= returned[kind]);
return
Suk Graduate rescue
{decision.mode === 'normal'
? 'Your skilled leader remained face up. Return one of your selected casualties to reserves instead of the Tanks.'
- : 'Your skilled leader survived. You may save up to three selected casualties. One stays in its original sector; the rest return to reserves.'}
+ : 'Your skilled leader survived. You may save up to three selected casualties. One stays in its original battle pool; the rest return to reserves.'}
Ordinary and elite counters keep their identity. Your paid battle support is unchanged.
+ {decision.territory.startsWith('homeworld:') &&
+ Saved counters already in your own Homeworld reserves stay in that same pool. This rescue does not move them between Homeworlds.
+
}
Forces to save
setChoice(event.target.value === '' ? null : Number(event.target.value))}>
+ value={choice ?? ''} onChange={(event) => {
+ setChoice(event.target.value === '' ? null : Number(event.target.value));
+ setSecondaryAllocation({ normal: 0, elite: 0 });
+ }}>
Choose your rescue
{decision.options.map((option, index) => {sukRescueLabel(option)} )}
{choice !== null && {sukRescueLabel(decision.options[choice])}
}
- {
- if (choice !== null && !busy) act({ type: 'decision', event: decision.event, choice });
+ {allocate && primary && secondary &&
+ Rescue return destinations
+
+ Choose how many returning counters go to {secondary.name}. The remainder goes to {primary.name}.
+ The visible default sends all returning counters to {primary.name}; this is a placement choice, not the Emperor revival rule.
+
+ {(['normal', 'elite'] as const).map(kind =>
+ {kind === 'normal' ? 'Ordinary forces' : 'Elite forces'} returning to {secondary.name} (0–{returned[kind]})
+ setSecondaryAllocation(current => ({ ...current, [kind]: Number(event.target.value) }))} />
+ {returned[kind] - secondaryAllocation[kind]} return to {primary.name}
+ )}
+ }
+ {
+ if (choice === null || busy || !allocationValid) return;
+ const destinations: SukReserveDestinations | undefined = allocate && primary && secondary ? {
+ [primary.id]: { normal: returned.normal - secondaryAllocation.normal, elite: returned.elite - secondaryAllocation.elite },
+ [secondary.id]: { ...secondaryAllocation },
+ } : undefined;
+ act({ type: 'decision', event: decision.event, choice, ...(destinations ? { destinations } : {}) });
}}>Resolve rescue
;
}
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index a7b3e64a..1b675157 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -17,6 +17,28 @@ open mode gates.
## Current checkpoint and work
+**Classic Homeworld + Skills or Discovery — bounded Development Verified.**
+Fresh Basic/Advanced two–six unique base factions retain original Homeworld
+setup with either all fourteen Skills or Discovery seven cards/eight tokens.
+The authorized revision permits these variants together or separately
+(physical PDF21–23). Other overlays and native expansion rosters remain
+separate in this checkpoint; the complete project scope is unchanged.
+Typed Suk casualties conserve native/visitor custody. Native rescue stays in
+its original home; external Emperor returns use an explicit split-home
+allocation, a documented implementation interpretation, not a revival ruling.
+Original Maker, entry and nested shipment mechanics retain physical sources.
+Affected tests94/94 across12 files, types/lint and build pass. Four original
+six-seat games finish2330/2330 actions with61 JSON continuations, on two
+unchanged source trees, not one final-tree certificate.
+Human mobile QA: `3NM6MS4T`v11 submits trained Suk allocation and retains
+three ordinary visitors in Junction, Kaitain11+1 elite and Salusa1+4 elite;
+`CLQYLURH`v11 submits Cistern entry for two ordinary/one Sardaukar while
+retaining native reserves and the earlier real internal transfer. Both
+surfaces are390px without overflow. See
+[Homeworld runtime](HOMEWORLD_RUNTIME_IMPLEMENTATION.md#classic-skills-and-discovery--6-october-2026)
+for exact scope, source provenance and limitations. Public starts, full-mode
+certification and protected deployment remain gated.
+
**Native trained Skills/Nexus/Discovery — bounded Development Verified:**
original paired E1 Ixians+Tleilaxu/Ix47, paired E2 CHOAM+Richese/CHOAM35+cache
and standalone Ecaz **or** Moritani with classics now compose all14 original
diff --git a/docs/DISCOVERY_PROTOTYPE.md b/docs/DISCOVERY_PROTOTYPE.md
index d659aa7d..e77aafe0 100644
--- a/docs/DISCOVERY_PROTOTYPE.md
+++ b/docs/DISCOVERY_PROTOTYPE.md
@@ -5,6 +5,20 @@ partial rules coverage. It connects physical components, real actions, private
controls, AI choices and saved continuation. Normal public Discovery and
Advanced start gates, and the publication gate, remain closed.
+## Classic Homeworld composition — 6 October 2026
+
+Fresh private Discovery setup may retain Homeworlds alone in Basic/Advanced
+with two–six unique base factions, original seven cards/eight tokens and
+native placement. Authorized PDF21–23 permits this composition. Existing
+Maker consequences and typed reserve rides, reveal/Collection, next-turn
+entry and nested shipment consume their real physical sources; no alternate
+Discovery mechanic was added. Real turn-one Emperor internal movement and
+turn-two split-home nested shipment retain actual custody. See
+[Homeworld runtime](HOMEWORLD_RUNTIME_IMPLEMENTATION.md#classic-skills-and-discovery--6-october-2026)
+for focused tests, original games, exact source batches and actual human
+Cistern submission. Native rosters and other overlays remain separate in
+this bounded profile, not excluded from the complete project goal.
+
## Source and setup boundary
The [component acquisition record](DISCOVERY_COMPONENTS.md) identifies the
diff --git a/docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md b/docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md
index dde822af..51dbf830 100644
--- a/docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md
+++ b/docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md
@@ -6,6 +6,63 @@ disabled.** It does not certify Homeworld battles, occupation, card effects or
complete Advanced/expansion games. The earlier component checkpoint remains
in [HOMEWORLD_IMPLEMENTATION.md](HOMEWORLD_IMPLEMENTATION.md).
+## Classic Skills and Discovery — 6 October 2026
+
+Fresh private prototypes now support Homeworlds with **either** Leader Skills
+or Discovery: Basic/Advanced, two–six unique base factions, no other overlays.
+Use existing `start-prototype.ts --profile leader-skills` or `--profile discovery`
+on a fresh ready lobby with Homeworlds enabled. Genuine game runner profiles
+are `homeworld-skills` and `homeworld-discovery`. Original fourteen skill
+offers, seven Discovery cards/eight tokens, first hands and native placement
+remain authoritative; no existing save is converted.
+
+The authorized root revision, physical PDF21–23, permits variants together
+or separately, keeps Homeworlds outside Arrakis and preserves their normal
+battle rules except printed modifications. This is permission to compose
+these prototypes, not a ruling for every interaction or a complete mode.
+Nullable custody now remains legal through actual skill assignment before
+the original force-placement stage.
+
+Suk quotes use physical normal/elite casualties and real native or visiting
+Homeworld pools. A native save remains at that same home; it cannot redeploy
+freely. An external save returns to actual native reserve homes. When Emperor
+has both homes, human controls require an exact ordinary/elite allocation;
+minimal bots allocate the return to the primary home. **Allowing both types
+at either native home is a prototype implementation interpretation**, not
+the PDF22 ordinary-revival placement rule and not a new user/publisher ruling.
+The quote validates before mutation and conserves physical custody, Tanks
+and reserves. Battle costs, leader death and card cleanup do not repeat.
+Bureaucrat's existing invoice redirection now accepts this bounded profile.
+Discovery's original Maker, next-turn entry and nested shipment paths need
+no alternate mechanics.
+
+Verification:17/17 new behavioral cases;94/94 affected tests across12 files;
+types/lint and build pass. Four original six-seat Basic/Advanced games using
+all four legal AI policies finish2330 accepted/2330 attempts,61 JSON
+continuations. Skills1125/1125/29 ran on unchanged source tree
+`4d3f1c5f99bf6a18cac6553a1ecb0bfbe4da35709e91f1384050506fbd98fc00`;
+Discovery1205/1205/32 on
+`64c0b8ae5286bfdab9019222bb652bf99a7b1f1478eb5b16024844503c930412`.
+Reports are `/tmp/dune-homeworld-classic-{skills,discovery}-games-20261006/report.json`.
+These are two source batches, not one final-tree certificate.
+
+Human QA uses isolated existing QA persistence on local port3089, not the
+protected server. Original fresh human rooms, original CLI setup and explicit
+unplayed lotteries feed genuine engine action programmes; no held components
+are injected. At390px, `3NM6MS4T`v11 actually submits trained Suk rescue:
+Junction retains three ordinary visitors; Kaitain has11 ordinary/1 Sardaukar,
+Salusa1 ordinary/4 Sardaukar, reserves17/5 and Tanks0. `CLQYLURH`v11 actually
+submits next-turn Cistern entry for two ordinary/one Sardaukar: reserves17/4,
+Kaitain12/0, Salusa1/4, preserving the earlier actual turn-one internal move.
+Both rendered surfaces have document width390; fresh state and accessibility
+were observed after submission. Nested shipment has engine/runtime coverage,
+not an additional human submission claim.
+
+Native expansion combinations, other overlays, Advanced Atreides Suk/KH,
+captured replacement, Diplomat Homeworld retreat and the separate Emperor
+Face Dance allocation question remain guarded. No public start, deployment,
+complete rules, recovery/privacy assurance or AI calibration is certified.
+
## Implemented behavior
`Game.homeworlds` is optional for existing saves. An enabled fresh lobby stores
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index da7166b0..cc600285 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,25 @@
# Dune implementation status
+## 6 October 2026 — Classic Homeworld Skills and Discovery
+
+Private fresh Basic/Advanced two–six unique base-faction profiles now compose
+Homeworlds with either original all14 Skills or Discovery7/8. Original setup
+and force placement remain; Skills custody stays nullable until placement.
+Typed Suk native/visitor/Arrakis rescue, explicit human split-home Emperor
+returns, minimal legal bot choices and Bureaucrat invoice support are
+connected. External return placement is an implementation interpretation,
+not the ordinary-revival ruling. Existing Discovery mechanics are reused.
+
+New cases17/17; affected union94/94 across12 files; types/lint/build pass.
+Four genuine six-seat games complete2330/2330 actions with61 JSON
+continuations on two unchanged source trees. Original human rooms
+`3NM6MS4T`v11 and `CLQYLURH`v11 actually submit Suk allocation and Cistern
+entry, respectively; physical native/visitor counters and390px surfaces
+observed afterward. Exact source hashes, programmes and boundaries are in
+[Homeworld runtime](HOMEWORLD_RUNTIME_IMPLEMENTATION.md#classic-skills-and-discovery--6-october-2026).
+Other overlays/native rosters, specific pending rulings, public starts,
+protected deployment and comprehensive certification remain separate.
+
## 6 October 2026 — Native trained Skills/Nexus/Discovery
Only the existing paired E1/E2 and standalone E3 skill/Nexus family predicates
diff --git a/docs/LEADER_SKILLS_RUNTIME.md b/docs/LEADER_SKILLS_RUNTIME.md
index 2643b0e9..adc9fde4 100644
--- a/docs/LEADER_SKILLS_RUNTIME.md
+++ b/docs/LEADER_SKILLS_RUNTIME.md
@@ -5,6 +5,21 @@ Normal mode starts and publication remain gated. The [source contract](LEADER_SK
contains all fourteen physical cards; the [capture interpretation](LEADER_SKILLS_CAPTURE.md)
records the fresh publisher/designer search requested by the user.
+## Classic Homeworld composition — 6 October 2026
+
+Fresh Basic/Advanced two–six unique base-faction private skill prototypes may
+retain Homeworlds alone with all fourteen original offers. Original setup
+waits for skill assignment before placing physical native reserves. Trained
+and normal Suk use source-qualified native/visitor/Arrakis pools and typed
+custody; explicit external Emperor return allocation is an implementation
+interpretation, not ordinary revival placement. Native saved counters stay
+at the same home. Existing payment redirection, original skill lifecycle and
+minimal legal bots are connected. Source,94-test union, two genuine games
+and actual390px human Suk submission are recorded in
+[Homeworld runtime](HOMEWORLD_RUNTIME_IMPLEMENTATION.md#classic-skills-and-discovery--6-october-2026).
+Other overlays/native families and pending specific interactions remain
+separate; no public or complete-mode gate changes.
+
## Native trained Skills/Nexus/Discovery — 6 October 2026
Fresh original `leader-skills --nexus-cards --discoveries` now extends the
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index f7b35167..acd9a59c 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -2,6 +2,19 @@
Updated 2 October 2026. This is the authoritative navigation index for existing decisions, not a replacement rulebook or a new ruling. Detailed linked contracts retain their source citations and exact supported boundaries. Runtime follow-ups supersede historical absence claims; passing tests establish software behavior, not publisher authority.
+## Classic Homeworld Skills/Discovery composition — 6 October 2026
+
+Authorized revision physical PDF21–23 permits these variants together or
+separately. Fresh bounded classic profiles compose either original Skills
+or Discovery with Homeworlds; no source claim that the modules are generally
+incompatible remains. [Homeworld runtime](HOMEWORLD_RUNTIME_IMPLEMENTATION.md#classic-skills-and-discovery--6-october-2026)
+records implementation and verification. Native Suk rescue stays at its
+original home; external Emperor returns explicitly choose exact ordinary/
+elite allocations between actual native homes. That allocation is a
+prototype implementation interpretation, **not** the PDF22 revival rule or
+a direct user/publisher ruling. Separate Face Dance allocation, Atreides
+Suk/KH and Diplomat retreat questions are unchanged.
+
## Authorized source amendment — 1 October 2026
The user added `UNOFFICIAL_Revised_Dune_Rulebook_v.2.3_web.pdf` at the
diff --git a/docs/SUK_GRADUATE_RULES.md b/docs/SUK_GRADUATE_RULES.md
index 78feb688..5d767232 100644
--- a/docs/SUK_GRADUATE_RULES.md
+++ b/docs/SUK_GRADUATE_RULES.md
@@ -4,6 +4,26 @@
bands to real battle resolution, player choices, AI and saved continuation.
Complete module, faction and publication gates remain closed.
+## Classic Homeworld composition — 6 October 2026
+
+The fresh classic Homeworld/Skills prototype preserves the printed rescue
+quantities while quoting actual native, visiting or Arrakis physical pools.
+Native saved counters remain at their original home, with no free internal
+redeployment. External returns use actual native reserve destinations.
+For Advanced Emperor, the owned panel chooses exact ordinary/elite counts
+between Kaitain and Salusa; bots choose the primary home. **This split is an
+implementation interpretation**, not the ordinary-revival placement rule
+on authorized PDF22 or a direct user/publisher ruling. The whole quote
+validates before mutation and preserves native custody, visitors, reserves
+and Tanks without repeating support, deaths or card disposal.
+
+Actual human trained mixed rescue, native normal/trained rescue, visitor and
+Arrakis cases, rejection boundaries and genuine game evidence are recorded
+in [Homeworld runtime](HOMEWORLD_RUNTIME_IMPLEMENTATION.md#classic-skills-and-discovery--6-october-2026).
+Advanced Atreides/KH, Diplomat retreat and the separate Emperor Face Dance
+allocation question remain unchanged. Other overlays/native rosters are
+not covered by this bounded composition.
+
## Source contract
Use the physical card and the [Leader Skills source archive](LEADER_SKILLS_RULES.md).
diff --git a/game/bots.ts b/game/bots.ts
index a35dc959..895224cd 100644
--- a/game/bots.ts
+++ b/game/bots.ts
@@ -2697,7 +2697,19 @@ function policyActions(g: GameView): Action[] {
score: option.normal + option.elite * (level > 0 ? 2.5 : 1) +
(option.kept ? 1 + (option.kept.kind === 'elite' && level > 0 ? 0.5 : 0) : 0),
})).sort((a, b) => b.score - a.score)
- .map(({ choice }) => ({ type: 'decision', event: d.event, choice }));
+ .map(({ choice }) => {
+ const option = d.options[choice];
+ const normal = option.normal - (option.kept?.kind === 'normal' ? 1 : 0);
+ const elite = option.elite - (option.kept?.kind === 'elite' ? 1 : 0);
+ if (!d.reserveHomes || normal + elite === 0) return { type: 'decision', event: d.event, choice };
+ const primary = d.reserveHomes.find(home => !home.secondary)!;
+ const destinations: Record = {};
+ for (const home of d.reserveHomes) destinations[home.id] = {
+ normal: home.id === primary.id ? normal : 0,
+ elite: home.id === primary.id ? elite : 0,
+ };
+ return { type: 'decision', event: d.event, choice, destinations };
+ });
}
if (d.kind === 'rihani') {
if (d.stage === 'offer') return [{ type: 'decision', event: d.event, draw: true }];
diff --git a/game/bureaucrat-payment.ts b/game/bureaucrat-payment.ts
index f591280b..95caffba 100644
--- a/game/bureaucrat-payment.ts
+++ b/game/bureaucrat-payment.ts
@@ -1,4 +1,4 @@
-import { classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, noOtherLeaderSkillModules, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile, type LeaderSkillProfile } from './leader-skill-profile';
+import { classicHomeworldLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, noOtherLeaderSkillModules, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile, type LeaderSkillProfile } from './leader-skill-profile';
import { classicDiscoveryLeaderSkillsProfile } from './discovery-module-profile';
export type BureaucratPaymentKind = 'auction' | 'shipment' | 'bribe' | 'revival';
export type BureaucratPaymentSource = {
@@ -39,7 +39,7 @@ export function bureaucratPaymentModeSupported(game: LeaderSkillProfile): boolea
return (noOtherLeaderSkillModules(game) &&
(!game.expansions.length ||
(game.expansions.length === 1 && game.expansions[0] === 'choam') ||
- nativeExpansionLeaderSkillsProfile(game))) || classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game) || strongholdLeaderSkillsProfile(game) || classicNexusLeaderSkillsProfile(game) || standaloneE3NexusLeaderSkillsProfile(game) || classicDiscoveryLeaderSkillsProfile(game) || nativeDiscoveryLeaderSkillsProfile(game);
+ nativeExpansionLeaderSkillsProfile(game))) || classicHomeworldLeaderSkillsProfile(game) || classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game) || strongholdLeaderSkillsProfile(game) || classicNexusLeaderSkillsProfile(game) || standaloneE3NexusLeaderSkillsProfile(game) || classicDiscoveryLeaderSkillsProfile(game) || nativeDiscoveryLeaderSkillsProfile(game);
}
export function bureaucratUsed(
used: readonly BureaucratPaymentUse[],
diff --git a/game/discovery-module-profile.ts b/game/discovery-module-profile.ts
index 09b3ecf0..9e9bbb3f 100644
--- a/game/discovery-module-profile.ts
+++ b/game/discovery-module-profile.ts
@@ -9,8 +9,29 @@ export function discoveryModuleProfile(game: FactionModuleProfile): boolean {
discoveryModeSupported(game);
}
+/** Original classic Homeworld/Discovery; live Arrakis-only token frames are expected. */
+export function classicHomeworldDiscoveryProfile(game: FactionModuleProfile): boolean {
+ if (!game.homeworlds || typeof game.advanced !== 'boolean' || game.discoveryEnabled !== true ||
+ game.expansions.length || game.players.length < 2 || game.players.length > 6 ||
+ game.leaderSkills || game.nexusCards || game.techTokens || game.strongholdCards ||
+ game.ecazTreachery || game.semutaPreview || game.advancedPreview || game.kullPreview ||
+ game.nexusKullPreview || game.guildBetrayalPreview || game.richeseBetrayalPreview ||
+ game.nexusIxianReplacementPreview || game.nexusIxianBetrayalPreview ||
+ game.nexusHarkonnenBetrayalPreview) return false;
+ for (let i = 0; i < game.players.length; i++) {
+ const player = game.players[i];
+ for (let j = 0; j < i; j++) if (game.players[j].faction === player.faction) return false;
+ let classic = false;
+ for (const faction of FACTIONS)
+ if (faction.id === player.faction) { classic = faction.expansion === 'base'; break; }
+ if (!classic) return false;
+ }
+ return true;
+}
+
/** The same roster/module contract during play; live Discovery frames are expected. */
export function discoveryModeSupported(game: FactionModuleProfile): boolean {
+ if (classicHomeworldDiscoveryProfile(game)) return true;
if (typeof game.advanced !== 'boolean' || game.discoveryEnabled !== true ||
game.players.length < 2 || game.players.length > 6 ||
(game.techTokens && game.players.length < 3) || game.expansions.length > 2 ||
diff --git a/game/engine.ts b/game/engine.ts
index d149bf5a..64c108f6 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -1,7 +1,7 @@
import { classicNexusModulesProfile, pairedNexusModulesProfile } from './nexus-module-profile';
import { nativeFactionTechProfile } from './faction-module-profile';
-import { classicDiscoveryLeaderSkillsProfile, classicDiscoveryNexusProfile, discoveryModeSupported, discoveryModuleProfile, pairedDiscoveryNexusProfile } from './discovery-module-profile';
-import { advancedMoritaniLeaderSkillsProfile, advancedNativeLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, pairedNexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile } from './leader-skill-profile';
+import { classicDiscoveryLeaderSkillsProfile, classicDiscoveryNexusProfile, classicHomeworldDiscoveryProfile, discoveryModeSupported, discoveryModuleProfile, pairedDiscoveryNexusProfile } from './discovery-module-profile';
+import { advancedMoritaniLeaderSkillsProfile, advancedNativeLeaderSkillsProfile, classicHomeworldLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, pairedNexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile } from './leader-skill-profile';
import { bribeTimingBlock, maximumBribe, type BribeOptions } from './bribe-options';
import { quoteSpicePlacement, stormExposesTerritory, stormSectorAfter, wormConsumesForces } from './disaster-rules';
import { isStormCardDistance, type StormCardComponent } from './storm-cards';
@@ -13,7 +13,7 @@ import {
canUsePlanetologistBattleSpecial,
type BattleLeaderSkill,
} from './leader-skill-combat';
-import { quoteSukRescue, sukReceiptSignature, sukRescueOptions, type SukForceGroup, type SukRescueOption, type SukRescueReceipt } from './suk-graduate';
+import { quoteSukPhysicalRescue, quoteSukRescue, SukPhysicalRescueError, sukReceiptSignature, sukRescueOptions, type SukForceGroup, type SukPhysicalRescueQuote, type SukPhysicalRescueReceipt, type SukReserveDestinations, type SukRescueOption, type SukRescueReceipt } from './suk-graduate';
import { leaderSkillStrongholdCount, sandmasterVictorySpice } from './leader-skill-battle-board';
import { beginRihani, chooseRihaniDraw, finishRihani, validateRihani, type RihaniReceipt, type RihaniSkill } from './rihani-decipherer';
import { planetologistMovementModeSupported, planetologistLeader, planetologistRange, selectedOriginElites, groundMovementRange, type PlanetologistMovement } from './planetologist-movement';
@@ -76,7 +76,7 @@ import { createHomeworldOccupationHistory, observeHomeworldOccupation, validateH
import { tupileIntelligenceTargets, quoteTupileIntelligenceRequest, type TupileIntelligenceCategory } from './tupile-intelligence';
import { quoteTupileIntelligenceAnswer } from './tupile-intelligence-answer';
import { createTupileIntelligenceState, appendTupileIntelligenceObservation, validateTupileIntelligenceState, type TupileIntelligenceState } from './tupile-intelligence-state';
-import { quoteHomeworldCustody } from './homeworld-custody';
+import { homeworldForceGroups, quoteHomeworldCustody } from './homeworld-custody';
import { quoteHomeworldShipment, type HomeworldShipmentIntent } from './homeworld-shipment';
import { quoteGuildHomeworldShipment } from './guild-homeworld-shipment';
import { quoteJunctionTransport } from './junction-transport';
@@ -871,7 +871,7 @@ export type Decision =
| { kind: 'techToken'; player: string; loser: string; choices: TechId[] }
| { kind: 'poisonTooth'; player: string; event?: string; physicalId?: string; copiedFrom?: string | null }
| { kind: 'stoneBurner'; player: string; event: string; physicalId?: string; copiedFrom?: string | null }
- | { kind: 'sukRescue'; player: string; event: string; territory: string; mode: 'normal' | 'skilled'; options: SukRescueOption[] }
+ | { kind: 'sukRescue'; player: string; event: string; territory: string; mode: 'normal' | 'skilled'; options: SukRescueOption[]; reserveHomes?: { id: string; name: string; secondary: boolean }[] }
| { kind: 'rihani'; player: string; event: string; stage: 'offer' | 'return' }
| { kind: 'fullPlanOffer'; player: string }
| {kind:'bureaucratPayment';player:string;event:string}
@@ -2093,7 +2093,7 @@ export type Game = {
signature: string;
};
winnerDiscards?: { cards: string[]; completed: boolean; signature: string };
- sukRescue?: { signature: string; completed: boolean };
+ sukRescue?: { signature: string; completed: boolean; physical?: SukPhysicalRescueReceipt };
ixSubstitution?: { signature: string; completed: boolean };
rihani?: { skill: RihaniSkill; cards: string[]; signature: string; completed: boolean; faceDanceStarted?: true };
sandmaster?: { leader: string; key: string; before: number; after: number };
@@ -9524,7 +9524,10 @@ export function initializeDiscoveryGameForAudit(state: Game): Game {
requireRule(discoveryModuleProfile(state), 'Discovery requires a fresh supported classic, E1/E2 or standalone E3 lobby without unrelated overlays.');
requireFreshBaseRuntime(state);
requireFreshFactionInventory(state);
- const g = initializeSetupGameForAudit(state, false, false, false, true, false, false, state.expansions.length > 0);
+ const homeworlds = classicHomeworldDiscoveryProfile(state);
+ if (homeworlds) requireRule(state.homeworlds?.custody === null,
+ 'Start Homeworld Discovery with original undealt native custody.');
+ const g = initializeSetupGameForAudit(state, homeworlds, false, false, true, false, false, state.expansions.length > 0);
if (byFaction(g, 'ecaz')) g.ecazOccupyPreview = true;
if (g.advanced && byFaction(g, 'moritani')) initializeMoritaniAssassinateState(g);
return g;
@@ -9532,6 +9535,13 @@ export function initializeDiscoveryGameForAudit(state: Game): Game {
/** Gated development setup; never dispatched by a player action or room route. */
export function initializeLeaderSkillsGameForAudit(state: Game): Game {
requireRule(!state.leaderSkills, 'Leader Skills cannot redeal existing skill cards.');
+ const homeworlds = classicHomeworldLeaderSkillsProfile(state);
+ if (state.homeworlds) {
+ requireRule(homeworlds && state.homeworlds.custody === null,
+ 'Homeworld Leader Skills require a fresh classic lobby without other optional modules or previews.');
+ requireFreshBaseRuntime(state);
+ requireFreshFactionInventory(state);
+ }
if (state.discoveryEnabled) {
requireRule(classicDiscoveryLeaderSkillsProfile(state) || nativeDiscoveryLeaderSkillsProfile(state) || classicNexusLeaderSkillsProfile(state), 'Discovery Leader Skills require a fresh supported classic or native lobby with optional original Nexus, Tech or Advanced Strongholds and no unrelated overlays.');
requireFreshBaseRuntime(state);
@@ -9540,7 +9550,7 @@ export function initializeLeaderSkillsGameForAudit(state: Game): Game {
const g = structuredClone(state);
g.leaderSkills = createLeaderSkills(random);
const classicNexus = classicNexusLeaderSkillsProfile(g);
- const initialized = initializeSetupGameForAudit(g, false, classicNexus || pairedNexusLeaderSkillsProfile(g) || standaloneE3NexusLeaderSkillsProfile(g), false, classicDiscoveryLeaderSkillsProfile(g) || nativeDiscoveryLeaderSkillsProfile(g) || (g.discoveryEnabled === true && classicNexus), true,
+ const initialized = initializeSetupGameForAudit(g, homeworlds, classicNexus || pairedNexusLeaderSkillsProfile(g) || standaloneE3NexusLeaderSkillsProfile(g), false, classicDiscoveryLeaderSkillsProfile(g) || nativeDiscoveryLeaderSkillsProfile(g) || (g.discoveryEnabled === true && classicNexus), true,
g.expansions.length === 1 && g.expansions[0] === 'choam', nativeExpansionLeaderSkillsProfile(g));
if (advancedMoritaniLeaderSkillsProfile(initialized)) initializeMoritaniAssassinateState(initialized);
if (nativeDiscoveryLeaderSkillsProfile(initialized) && byFaction(initialized, 'ecaz')) initialized.ecazOccupyPreview = true;
@@ -21643,8 +21653,8 @@ function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) {
g.pendingSukRescue = {
event: g.lastBattleContext.event, turn: g.turn, player: winner.id,
territory: b.territory, skill: quote.sukGraduate,
- commitment, pool: sukForcePool(winner, b.territory),
- cards: [...quote.winnerCards], physical: sukPhysicalSignature(winner), losses: null, signature: '',
+ commitment, pool: sukForcePool(g, winner, b.territory),
+ cards: [...quote.winnerCards], physical: sukPhysicalSignature(winner, g), losses: null, signature: '',
...(winner.faction === 'ixians' ? { eliteOrigins: true as const } : {}),
...(fixedSukLoss ? { occupyCasualties: { ...structuredClone(casualtyCommitment), owner: casualtyCommitment.owner! } } : {}),
};
@@ -21786,9 +21796,10 @@ function settleWinnerCasualties(
pending.losses = { ...choice };
updateSukReceipt(g);
const options = sukRescueOptions(pending.skill, pending.pool, choice, pending.eliteOrigins);
- if (options.length === 1) settleSukRescue(g, options[0], true);
+ const reserveHomes = sukReserveHomes(g, p, to, options);
+ if (options.length === 1 && !reserveHomes) settleSukRescue(g, options[0], true);
else g.decision = { kind: 'sukRescue', player: p.id, event: pending.event,
- territory: to, mode: pending.skill.mode, options };
+ territory: to, mode: pending.skill.mode, options, ...(reserveHomes ? { reserveHomes } : {}) };
return;
}
if (to.startsWith('homeworld:')) homeworldBattleLossIntegrity(g);
@@ -21876,13 +21887,28 @@ function completeIxSubstitution(g: Game) {
g.pendingIxSubstitution = null;
if (g.lastBattleContext?.ixSubstitution) g.lastBattleContext.ixSubstitution.completed = true;
}
-function sukForcePool(p: Player, territory: string): SukForceGroup[] {
+function sukForcePool(g: Game, p: Player, territory: string): SukForceGroup[] {
+ if (territory.startsWith('homeworld:')) {
+ const { normal, elite } = combatArmy(g, p.id, territory);
+ return [{ key: territory, normal, elite }];
+ }
return Object.entries(p.forces).filter(([key]) => splitLocation(key).territory === territory)
.map(([key, total]) => ({ key, normal: total - (p.elites?.forces[key] ?? 0), elite: p.elites?.forces[key] ?? 0 }));
}
-function sukPhysicalSignature(p: Player) {
+function sukPhysicalSignature(p: Player, g?: Game) {
return JSON.stringify({ forces: p.forces, elites: p.elites ?? null,
- reserves: p.reserves, tanks: p.tanks, battleLosses: p.battleLosses });
+ reserves: p.reserves, tanks: p.tanks, battleLosses: p.battleLosses,
+ ...(g?.homeworlds?.custody ? { homeworlds: g.homeworlds.custody } : {}) });
+}
+function sukReserveHomes(g: Game, player: Player, territory: string, options: readonly SukRescueOption[]): Extract['reserveHomes'] {
+ if (!g.homeworlds?.custody || !classicHomeworldLeaderSkillsProfile(g) ||
+ !options.some(option => option.normal + option.elite > (option.kept ? 1 : 0))) return;
+ const homes = homeworldRule(() => homeworldForceGroups(homeworldContext(g), g.homeworlds!.custody!));
+ if (homes.some(home => home.id === territory && home.native === player.id)) return;
+ const native = homes.filter(home => home.native === player.id);
+ if (native.length > 1) return native.map(home => ({
+ id: home.id, name: combatLocationName(g, home.id), secondary: !!home.secondary,
+ }));
}
function updateSukReceipt(g: Game) {
const pending = g.pendingSukRescue!;
@@ -21904,9 +21930,9 @@ function sukRescueIntegrity(g: Game) {
context.result === 'normal' && context.winner === pending.player && context.territory === pending.territory &&
context.sukRescue?.signature === pending.signature && !context.sukRescue.completed &&
pending.signature === sukReceiptSignature(pending) &&
- pending.physical === sukPhysicalSignature(player) &&
+ pending.physical === sukPhysicalSignature(player, g) &&
(pending.eliteOrigins === undefined || pending.eliteOrigins === true && player.faction === 'ixians') &&
- JSON.stringify(pending.pool) === JSON.stringify(sukForcePool(player, pending.territory)) &&
+ JSON.stringify(pending.pool) === JSON.stringify(sukForcePool(g, player, pending.territory)) &&
pending.commitment.forces.normal === pending.pool.reduce((sum, group) => sum + group.normal, 0) &&
pending.commitment.forces.elite === pending.pool.reduce((sum, group) => sum + group.elite, 0) &&
JSON.stringify(pending.commitment.options) === JSON.stringify(casualtyOptions(pending.commitment.forces, pending.commitment.dial, pending.commitment.support)) &&
@@ -21916,7 +21942,8 @@ function sukRescueIntegrity(g: Game) {
requireRule(pending.commitment.options.some((o) => JSON.stringify(o) === JSON.stringify(pending.losses)) &&
decisions.length === 1 && decisions.every((d) => d.player === pending.player && d.event === pending.event &&
d.territory === pending.territory && d.mode === pending.skill.mode &&
- JSON.stringify(d.options) === JSON.stringify(sukRescueOptions(pending.skill, pending.pool, pending.losses!, pending.eliteOrigins))),
+ JSON.stringify(d.options) === JSON.stringify(sukRescueOptions(pending.skill, pending.pool, pending.losses!, pending.eliteOrigins)) &&
+ JSON.stringify(d.reserveHomes) === JSON.stringify(sukReserveHomes(g, player, pending.territory, d.options))),
'The saved Suk Graduate rescue choices differ from the committed casualties.');
} else {
const lossDecision = homeworldSavedDecisions(g).find((d) => d.kind === 'battleLosses' && d.player === pending.player);
@@ -21931,10 +21958,10 @@ function sukRescueIntegrity(g: Game) {
'The Suk Graduate rescue lost its casualty continuation.');
}
}
-function settleSukRescue(g: Game, option: SukRescueOption, automatic: boolean) {
+function settleSukRescue(g: Game, option: SukRescueOption, automatic: boolean, destinations?: SukReserveDestinations) {
const pending = g.pendingSukRescue!;
const player = getPlayer(g, pending.player);
- requireRule(pending.losses && pending.physical === sukPhysicalSignature(player),
+ requireRule(pending.losses && pending.physical === sukPhysicalSignature(player, g),
'The Suk Graduate rescue no longer has its committed physical counters.');
const sardaukar = g.nexusSardaukarHistory?.find(record =>
record.receipt.battle === pending.event && record.receipt.owner === pending.player);
@@ -21943,8 +21970,20 @@ function settleSukRescue(g: Game, option: SukRescueOption, automatic: boolean) {
dial:sardaukar.casualties.dial,support:sardaukar.casualties.support,options:sardaukar.casualties.options}) &&
sardaukar.casualties.options.some(choice => JSON.stringify(choice) === JSON.stringify(pending.losses)),
'The Suk rescue lost its original Nexus Sardaukar casualty allocation.');
- const quote = quoteSukRescue(pending.skill, pending.pool, pending.losses, option, pending.eliteOrigins);
- for (const group of quote.removed) {
+ let physical: SukPhysicalRescueQuote | undefined;
+ if (classicHomeworldLeaderSkillsProfile(g)) {
+ try {
+ physical = homeworldRule(() => quoteSukPhysicalRescue(homeworldLossContext(g), g.homeworlds!.custody!, {
+ player: player.id, territory: pending.territory, skill: pending.skill,
+ pool: pending.pool, losses: pending.losses!, option, destinations,
+ }));
+ } catch (error) {
+ if (error instanceof SukPhysicalRescueError) throw new RuleError(error.message);
+ throw error;
+ }
+ } else requireRule(destinations === undefined, 'Suk reserve destinations require the original Homeworld composition.');
+ const quote = physical ? null : quoteSukRescue(pending.skill, pending.pool, pending.losses, option, pending.eliteOrigins);
+ if (!physical || !pending.territory.startsWith('homeworld:')) for (const group of physical?.removed ?? quote!.removed) {
player.forces[group.key] -= group.normal + group.elite;
if (!player.forces[group.key]) delete player.forces[group.key];
if (player.elites) {
@@ -21952,12 +21991,18 @@ function settleSukRescue(g: Game, option: SukRescueOption, automatic: boolean) {
if (!player.elites.forces[group.key]) delete player.elites.forces[group.key];
}
}
- player.reserves += quote.reserves.normal + quote.reserves.elite;
- player.tanks += quote.tanks.normal + quote.tanks.elite;
- player.battleLosses += quote.tanks.normal + quote.tanks.elite;
- if (player.elites) {
- player.elites.reserves += quote.reserves.elite;
- player.elites.tanks += quote.tanks.elite;
+ if (physical) {
+ commitHomeworldResources(g, physical);
+ if (pending.territory.startsWith('homeworld:')) g.homeworldBattleLoss = null;
+ g.lastBattleContext!.sukRescue!.physical = physical.receipt;
+ } else {
+ player.reserves += quote!.reserves.normal + quote!.reserves.elite;
+ player.tanks += quote!.tanks.normal + quote!.tanks.elite;
+ player.battleLosses += quote!.tanks.normal + quote!.tanks.elite;
+ if (player.elites) {
+ player.elites.reserves += quote!.reserves.elite;
+ player.elites.tanks += quote!.tanks.elite;
+ }
}
if (sardaukar?.casualties) {
sardaukar.casualties.outcome = 'complete';
@@ -21965,9 +22010,16 @@ function settleSukRescue(g: Game, option: SukRescueOption, automatic: boolean) {
}
g.pendingSukRescue = null;
g.lastBattleContext!.sukRescue!.completed = true;
- log(g, `${player.name}'s Suk Graduate saved ${option.normal} ordinary and ${option.elite} elite forces: ${option.kept ? `1 remained in sector ${splitLocation(option.kept.key).sector}` : 'none remained in the battle territory'}, ${quote.reserves.normal + quote.reserves.elite} returned to reserves, and ${quote.tanks.normal + quote.tanks.elite} casualties went to the Tanks.${automatic ? ' The only legal rescue was applied automatically.' : ''}`, { faction: player.faction, name: 'Suk Graduate rescue' });
+ const native = physical?.receipt.source === 'native-homeworld';
+ const returned = native ? 0 : option.normal + option.elite - (option.kept ? 1 : 0);
+ const tanks = physical?.receipt.tanks ?? quote!.tanks;
+ const kept = native ? `${option.normal + option.elite} saved counters remained in their original Homeworld reserve pool`
+ : option.kept ? option.kept.key.startsWith('homeworld:')
+ ? `1 remained on ${combatLocationName(g, option.kept.key)}` : `1 remained in sector ${splitLocation(option.kept.key).sector}`
+ : 'none remained in the battle territory';
+ log(g, `${player.name}'s Suk Graduate saved ${option.normal} ordinary and ${option.elite} elite forces: ${kept}, ${returned} returned to reserves, and ${tanks.normal + tanks.elite} casualties went to the Tanks.${automatic ? ' The only legal rescue was applied automatically.' : ''}`, { faction: player.faction, name: 'Suk Graduate rescue' });
observeOccupation(g);
- if (pending.eliteOrigins) stageIxSubstitution(g, player, pending.territory, pending.cards, quote.eliteTanks!);
+ if (pending.eliteOrigins) stageIxSubstitution(g, player, pending.territory, pending.cards, quote!.eliteTanks!);
finishWinner(g, player, pending.territory, pending.cards);
}
function winnerDiscardSignature(pending: NonNullable) {
@@ -29036,10 +29088,10 @@ function applyActionInner(
const choice = decision.options[integer(action.choice, 0, decision.options.length - 1, 'Casualty choice')];
settleHomeworldExplosion(g, choice, false);
} else if (decision.kind === 'sukRescue') {
- requireRule(action.event === decision.event && Object.keys(action).every((key) => ['type', 'event', 'choice'].includes(key)),
+ requireRule(action.event === decision.event && Object.keys(action).every((key) => ['type', 'event', 'choice', 'destinations'].includes(key)),
'Choose only the current Suk Graduate rescue.');
const option = decision.options[integer(action.choice, 0, decision.options.length - 1, 'Rescue choice')];
- settleSukRescue(g, option, false);
+ settleSukRescue(g, option, false, action.destinations as SukReserveDestinations | undefined);
} else if (decision.kind === 'rihani') {
actRihani(g, p, decision, action);
} else if (decision.kind === 'battleLosses') {
diff --git a/game/homeworld-game.ts b/game/homeworld-game.ts
index 01bf932f..06201e29 100644
--- a/game/homeworld-game.ts
+++ b/game/homeworld-game.ts
@@ -38,7 +38,7 @@ export function homeworldGameIntegrity(g: Game): void {
const beforeForces =
g.status === 'lobby' ||
(g.status === 'setup' &&
- (g.setupStage === 'prediction' || g.setupStage === 'traitors'));
+ (g.setupStage === 'prediction' || (g.setupStage === 'leaderSkills' && !!g.leaderSkills) || g.setupStage === 'traitors'));
if (beforeForces) {
if (state.custody !== null)
throw new HomeworldCustodyError(
diff --git a/game/leader-skill-profile.ts b/game/leader-skill-profile.ts
index ecffcdfe..53ae3050 100644
--- a/game/leader-skill-profile.ts
+++ b/game/leader-skill-profile.ts
@@ -376,6 +376,28 @@ export function nativeTechLeaderSkillsProfile(game: LeaderSkillProfile): boolean
return !!game.techTokens && nativeExpansionLeaderSkillsProfile(game);
}
+/** Original classic Homeworld/Skills; custody and undealt components belong to setup. */
+export function classicHomeworldLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
+ const players = game.players;
+ if (!game.homeworlds || typeof game.advanced !== 'boolean' || game.expansions.length ||
+ !players || players.length < 2 || players.length > 6 ||
+ game.nexusCards || game.discoveries || game.discoveryEnabled || game.discoveryStash ||
+ game.greatMaker || game.techTokens || game.strongholdCards || game.ecazTreachery ||
+ game.semutaPreview || game.moritaniAssassinatePreview || game.advancedPreview ||
+ game.kullPreview || game.nexusKullPreview || game.guildBetrayalPreview ||
+ game.richeseBetrayalPreview || game.nexusIxianReplacementPreview ||
+ game.nexusIxianBetrayalPreview || game.nexusHarkonnenBetrayalPreview ||
+ game.mentatQuestionPreview || game.spiceBankerIncomePreview) return false;
+ for (let i = 0; i < players.length; i++) {
+ for (let j = 0; j < i; j++) if (players[j].faction === players[i].faction) return false;
+ let classic = false;
+ for (const faction of FACTIONS)
+ if (faction.id === players[i].faction) { classic = faction.expansion === 'base'; break; }
+ if (!classic) return false;
+ }
+ return true;
+}
+
/** Shared by rule quotes, controls and minimal legal bot participation. */
export function ordinaryLeaderSkillModeSupported(game: LeaderSkillProfile): boolean {
return (noOtherLeaderSkillModules(game) &&
@@ -383,5 +405,6 @@ export function ordinaryLeaderSkillModeSupported(game: LeaderSkillProfile): bool
classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game) ||
strongholdLeaderSkillsProfile(game) || classicNexusLeaderSkillsProfile(game) ||
pairedNexusLeaderSkillsProfile(game) || standaloneE3NexusLeaderSkillsProfile(game) ||
- classicDiscoveryLeaderSkillsProfile(game) || nativeDiscoveryLeaderSkillsProfile(game);
+ classicDiscoveryLeaderSkillsProfile(game) || nativeDiscoveryLeaderSkillsProfile(game) ||
+ classicHomeworldLeaderSkillsProfile(game);
}
diff --git a/game/reference.ts b/game/reference.ts
index fc591ccc..0ad80ca5 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -3820,6 +3820,7 @@ export const RULE_TOPICS: RuleTopic[] = [
).join(' '),
steps: [
'Homeworlds are an optional module from Ecaz & Moritani, available under the official rules with Basic or Advanced play. They are separate locations outside Arrakis; they do not receive ordinary storm movement or count as ordinary strongholds.',
+ 'Fresh private classic Homeworld prototypes may compose either all fourteen Leader Skills or Discovery seven cards/eight tokens, Basic/Advanced two through six unique base factions. Original setup and native placement remain. Typed native Suk saves stay at the same home; external Emperor returns explicitly allocate normal/elite counters to actual native homes. That allocation is an implementation interpretation, not ordinary revival placement. Existing Maker, next-turn entry and nested shipment retain physical custody. Native expansion rosters and other overlays remain separate in this bounded checkpoint; public starts and specific pending interactions stay gated.',
'Each faction uses its own card and matching world token. In Basic play the Emperor uses Kaitain alone. Advanced play adds Salusa Secundus for its Sardaukar; movement can transfer both force types between the two Emperor worlds.',
'Native reserves determine high or low population. Salusa counts Sardaukar, while the other cards count the faction’s reserves there. Reaching the high face’s minimum restores high population. Occupation also applies the low penalty.',
'Low population normally grants one additional free force revival and one additional bank spice when collecting CHOAM Charity. Salusa grants neither bonus; CHOAM’s Tupile reverses the usual pattern by granting an advantage while low and a penalty while high.',
@@ -4080,6 +4081,7 @@ export const RULE_TOPICS: RuleTopic[] = [
searchText: 'Hiereg Smuggler Jacurutu Sietch Cistern Ecological Testing Station Shrine Orgiz Processing Station Treachery Card Stash Spice Stash Ornithopter',
steps: [
'Fresh Discovery development setup supports classic, selected Ixian/Tleilaxu/CHOAM/Richese families, or standalone Ecaz or Moritani with classic opponents in Basic or Advanced, with seven extra Spice Cards and eight tokens. Original unused Tech Tokens may be selected with three through six seats; original Stronghold Cards require Advanced. Other modules, paired or mixed Ecaz/Moritani families and normal public starts stay gated.',
+ 'A separate fresh classic Discovery entry retains Homeworlds alone in Basic/Advanced two through six unique base factions, original seven cards/eight tokens and native reserve placement. Maker consequences, typed reserve ride, next-turn entry and nested shipment use actual board/native groups. Other overlays and native expansion rosters remain separate in this bounded profile, not excluded from the full implementation goal.',
'The fresh Leader Skills entry may explicitly add Discoveries with the original classic or supported native family decks, all fourteen skill components, optional original Tech at three or more seats and Advanced Stronghold Cards. Classic setups additionally accept --nexus-cards, retaining all12 and the actual settled-alliance end-Spice closing deal after Great Maker and both Advanced piles. Existing native roster and Duke/assassination/Harkonnen restrictions remain. Actual starting hands and offered training are preserved. Physical free entry, nested tariffs, stash capacity, typed rescue, native aftermath and printed invoices use original sources. Homeworlds, independent variants and separate Banker/Mentat previews remain outside this composition.',
'The same fresh entry also adds --nexus-cards --discoveries to paired Ixians+Tleilaxu, paired CHOAM+Richese or standalone Ecaz OR Moritani with classics: original all14 offered training, all12 Nexus and Discovery7/8, Basic/Advanced two through six, optional Tech at three or more seats and Advanced Strongholds. Original setup, first hands, offers and native components stay authoritative. Real Maker losses/votes/typed rides, both Advanced piles and settled alliance precede the end-Spice closing draw. E1 Suboid Cunning settles typed Suk rescue before one equal Cyborg Tank substitution; original cleanup and mandatory winner Tech precede a matching Face Dance. E2 keeps the signed two-marker frame and one invoice. Ecaz keeps quiet living/unclaimed Duke separate from training and a real owner-labelled mandatory Occupy; Moritani keeps skill-first assassination and one private Mentat replacement. Mixed/E3 pairs, Homeworlds, shared Duke, borrowed Smuggler/pair companions, native free returns, captured/foreign training, Basic odd Ecaz and Advanced Harkonnen stay separate.',
'A separate fresh Nexus entry may explicitly add Discoveries to classic factions or both native Ixians/Tleilaxu or CHOAM/Richese with classics, one original family deck and no Skills. Original Tech requires three or more seats; Stronghold Cards require Advanced. End-Mentat ownership and next-turn physical entry remain. Great Maker finishes its losses, votes and typed reserve ride before ordinary Nexus settlement; cards are offered only at the end of the entire Spice Blow after both Advanced piles and settled alliance, not immediately or at Mentat. Source-clear return ledgers, Guild-priced nested shipment and signed native one-invoice marker pairs retain physical pieces and original capacities. E3/mixed families, Homeworlds, unrelated previews and unresolved mixed-module Fremen Betrayal stay separate from this no-Skills entry.',
@@ -4642,6 +4644,7 @@ export const RULE_TOPICS: RuleTopic[] = [
developmentStage: 'Prototyped',
summary: 'Fourteen physical skills, private assignment choices, five battle disciplines, Planetologist movement/battle effects and Suk Graduate rescues, Mentat/Bureaucrat scoring and Bureaucrat payment redirection, Sandmaster victory spice, Rihani exchanges, optional Smuggler shipping and unmodified battle collection, Sandmaster route and worm-ride collection, sealed Spice Banker battle spending and optional Diplomat base-defense copying in development tables. Other effects and combined-module acceptance remain unfinished.',
steps: [
+ 'A separate fresh classic Leader Skills entry retains Homeworlds alone in Basic/Advanced two through six unique base factions and all fourteen original offers. Original native placement follows training. Normal/trained Suk uses actual native or visiting force pools; native saved counters remain at that home. External Emperor returns require exact ordinary/elite allocations to actual native homes, an implementation interpretation rather than the ordinary revival rule. Bureaucrat invoice redirection and minimal legal bots are connected. Native expansion rosters, other overlays and specific pending interactions remain separate.',
'Basic Moritani retains base opponents including Harkonnen. Its separate bounded Advanced classic/non-Harkonnen roster now composes the original assassination preview and all fourteen skills: winner Suk rescue and Rihani choice finish first, then an eligible different trained disc may be assassinated with its exact card return and printed bounty. No casualties repeat; original set-aside and one private Mentat replacement remain. See Moritani with Leader Skills for the exact scope.',
'Fresh Advanced profiles compose native Ixians, Tleilaxu, CHOAM and/or Richese with classic opponents and distinct required family decks. All fourteen skills and native starts remain. Auditor stays unskilled; cyborg casualties, original CHOAM support and Richese capped marker revelation remain physical. Foreign revival creates no new skill. Native Ixian Technology on Richese lots, own mixed No-Field, captured replacement, Advanced Atreides Suk/KH and unrelated overlays retain their guards.',
'Fresh three-through-six-seat Basic/Advanced development tables may compose all fourteen Skills with original Tech Tokens: classic factions, supported E1/E2 native families, or standalone Ecaz/Moritani with the exact ecaz deck and original roster exclusions. Starting cards/cache, skill/Traitor choices and printed/first-Storm assignment stay genuine. Existing leader-skills/banker-income entry preserves unused selected tokens; mixed E3 families, other overlays, public activation and saved-game conversion remain separate.',
diff --git a/game/suk-graduate.ts b/game/suk-graduate.ts
index 8ff43c62..534d3573 100644
--- a/game/suk-graduate.ts
+++ b/game/suk-graduate.ts
@@ -1,5 +1,8 @@
import type { Casualties, CombatForces } from './combat';
import type { BattleLeaderSkill } from './leader-skill-combat';
+import { homeworldForceGroups, quoteHomeworldCustody, type HomeworldCustody, type HomeworldForces } from './homeworld-custody';
+import { quoteHomeworldCombatLoss, type HomeworldCombatLossContext, type HomeworldCombatLossPlayer } from './homeworld-combat-loss';
+import type { NativeReserveSelections } from './homeworld-native-reserves';
export type SukGraduateSkill = { leader: string; mode: 'normal' | 'skilled' };
export type SukForceGroup = { key: string; normal: number; elite: number };
@@ -29,6 +32,34 @@ export type SukRescueReceipt = {
signature: string;
};
+/** Non-revival placement choice for counters returning from Arrakis or a foreign
+ * Homeworld. It never relocates rescued counters already in native reserves. */
+export type SukReserveDestinations = NativeReserveSelections;
+export type SukPhysicalRescueRequest = {
+ player: string;
+ territory: string;
+ skill: SukGraduateSkill;
+ pool: readonly SukForceGroup[];
+ losses: Casualties;
+ option: SukRescueOption;
+ destinations?: SukReserveDestinations;
+};
+export type SukPhysicalRescueReceipt = {
+ player: string;
+ territory: string;
+ source: 'arrakis' | 'native-homeworld' | 'visitor-homeworld';
+ saved: HomeworldForces;
+ kept: SukRescueOption['kept'];
+ destinations: Record;
+ tanks: HomeworldForces;
+};
+export type SukPhysicalRescueQuote = {
+ custody: HomeworldCustody;
+ players: HomeworldCombatLossPlayer[];
+ removed: SukForceGroup[];
+ receipt: SukPhysicalRescueReceipt;
+};
+
export function sukReceiptSignature(receipt: SukRescueReceipt): string {
const { signature: _signature, ...value } = receipt;
return JSON.stringify(value);
@@ -132,3 +163,104 @@ export function quoteSukRescue(skill: SukGraduateSkill, pool: readonly SukForceG
tanks: { normal: losses.normal - option.normal, elite: losses.elite - option.elite },
};
}
+
+export class SukPhysicalRescueError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'SukPhysicalRescueError';
+ }
+}
+
+/** Detached classic Homeworld settlement, including Arrakis rescue returns.
+ * Printed Suk saves only real casualties. Native Homeworld forces already ARE
+ * reserves, so every saved native counter remains in its original pool.
+ *
+ * For external returns the single native home is unambiguous. The application
+ * asks the player for a typed allocation when two homes exist; this is not the
+ * printed Emperor revival placement rule or a ruling on other return routes. */
+export function quoteSukPhysicalRescue(
+ context: HomeworldCombatLossContext,
+ custody: HomeworldCustody,
+ request: SukPhysicalRescueRequest,
+): SukPhysicalRescueQuote {
+ const homes = homeworldForceGroups(context, custody);
+ const nativeHomes = homes.filter(home => home.native === request.player);
+ if (!nativeHomes.length) throw new SukPhysicalRescueError('Suk Graduate needs a seated native reserve owner.');
+ // Reuse the existing complete twenty-counter and special-identity validator.
+ quoteHomeworldCombatLoss(context, custody, {
+ location: nativeHomes[0].id, player: request.player, losses: { normal: 0, elite: 0 },
+ });
+ const rescue = quoteSukRescue(request.skill, request.pool, request.losses, request.option);
+ const home = homes.find(candidate => candidate.id === request.territory);
+ if (request.territory.startsWith('homeworld:') && !home)
+ throw new SukPhysicalRescueError('Suk Graduate needs its original canonical battle Homeworld.');
+ const native = home?.native === request.player;
+ if (home) {
+ const army = home.forces[request.player] ?? { normal: 0, elite: 0 };
+ if (request.pool.length !== 1 || request.pool[0].key !== home.id ||
+ request.pool[0].normal !== army.normal || request.pool[0].elite !== army.elite)
+ throw new SukPhysicalRescueError('Suk Graduate needs the exact native or visitor battle army.');
+ } else if (request.pool.some(group => group.key.startsWith('homeworld:')))
+ throw new SukPhysicalRescueError('Arrakis Suk Graduate casualties cannot borrow Homeworld pools.');
+
+ const returned = native ? { normal: 0, elite: 0 } : rescue.reserves;
+ const destinations: Record = {};
+ if (request.destinations !== undefined) {
+ if (!request.destinations || typeof request.destinations !== 'object' || Array.isArray(request.destinations))
+ throw new SukPhysicalRescueError('Choose a typed Suk Graduate native-home allocation.');
+ for (const [location, forces] of Object.entries(request.destinations)) {
+ if (!nativeHomes.some(candidate => candidate.id === location) ||
+ !forces || typeof forces !== 'object' || Array.isArray(forces) ||
+ Object.keys(forces).length !== 2 ||
+ !Object.hasOwn(forces, 'normal') || !Object.hasOwn(forces, 'elite') ||
+ ![forces.normal, forces.elite].every(value => Number.isSafeInteger(value) && value >= 0))
+ throw new SukPhysicalRescueError('Suk Graduate reserve destinations must be exact typed native Homeworlds.');
+ destinations[location] = { normal: forces.normal, elite: forces.elite };
+ }
+ const total = Object.values(destinations).reduce((sum, forces) => ({
+ normal: sum.normal + forces.normal, elite: sum.elite + forces.elite,
+ }), { normal: 0, elite: 0 });
+ if (total.normal !== returned.normal || total.elite !== returned.elite)
+ throw new SukPhysicalRescueError('Suk Graduate destinations must match only the counters actually returning to reserves.');
+ } else if (returned.normal + returned.elite) {
+ if (nativeHomes.length !== 1)
+ throw new SukPhysicalRescueError('Choose the Suk Graduate return allocation between your native Homeworlds.');
+ destinations[nativeHomes[0].id] = { ...returned };
+ }
+ const removed = native
+ ? [{ key: request.territory, ...rescue.tanks }]
+ : rescue.removed;
+ const removedTotal = removed.reduce((sum, group) => ({
+ normal: sum.normal + group.normal, elite: sum.elite + group.elite,
+ }), { normal: 0, elite: 0 });
+ const transaction = quoteHomeworldCustody(context, custody, [
+ ...(home ? [{ homeworld: home.id, player: request.player,
+ withdraw: removedTotal, deposit: { normal: 0, elite: 0 } }] : []),
+ ...Object.entries(destinations).filter(([, forces]) => forces.normal + forces.elite > 0)
+ .map(([homeworld, deposit]) => ({
+ homeworld, player: request.player, withdraw: { normal: 0, elite: 0 }, deposit,
+ })),
+ ]);
+ const players: HomeworldCombatLossPlayer[] = context.players.map((player, index) => ({
+ ...transaction.players[index],
+ tanks: player.tanks + (player.id === request.player ? rescue.tanks.normal + rescue.tanks.elite : 0),
+ eliteTanks: player.eliteTanks + (player.id === request.player ? rescue.tanks.elite : 0),
+ battleLosses: player.battleLosses + (player.id === request.player ? rescue.tanks.normal + rescue.tanks.elite : 0),
+ boardForces: !home && player.id === request.player
+ ? { normal: player.boardForces.normal - removedTotal.normal, elite: player.boardForces.elite - removedTotal.elite }
+ : { ...player.boardForces },
+ }));
+ quoteHomeworldCombatLoss({ advanced: context.advanced, players }, transaction.state, {
+ location: nativeHomes[0].id, player: request.player, losses: { normal: 0, elite: 0 },
+ });
+ return {
+ custody: transaction.state, players, removed,
+ receipt: {
+ player: request.player, territory: request.territory,
+ source: home ? native ? 'native-homeworld' : 'visitor-homeworld' : 'arrakis',
+ saved: { normal: request.option.normal, elite: request.option.elite },
+ kept: request.option.kept ? { ...request.option.kept } : null,
+ destinations, tanks: { ...rescue.tanks },
+ },
+ };
+}
diff --git a/tests/fixture-homeworld-classic-discovery.ts b/tests/fixture-homeworld-classic-discovery.ts
new file mode 100644
index 00000000..3809d201
--- /dev/null
+++ b/tests/fixture-homeworld-classic-discovery.ts
@@ -0,0 +1,197 @@
+import assert from 'node:assert/strict';
+import {
+ applyAction, createGame, initializeDiscoveryGameForAudit, joinGame, newPlayer,
+ viewGame, type Action, type Game,
+} from '../game/engine';
+import { classicHomeworldDiscoveryProfile } from '../game/discovery-module-profile';
+import { DISCOVERY_CARD_PLACEMENTS, DISCOVERY_SPICE_CARDS, DISCOVERY_TOKEN_BY_ID, validateDiscoveryState,
+ type DiscoveryLocationId, type DiscoveryOpaqueTokenId } from '../game/discoveries';
+import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
+import { homeworldGameIntegrity } from '../game/homeworld-game';
+import { nativeShipmentSources } from '../game/homeworld-options';
+import { nextSpiceBankerIncomeNativeStep } from './fixture-spice-banker-income';
+
+export type HomeworldClassicDiscoveryStep = { actor: string; action: Action };
+export type HomeworldClassicDiscoveryOptions = {
+ /** Original fresh lobby/staged setup; existing first hands are not redealt. */
+ initial?: Game;
+ advanced?: boolean;
+ seats?: 2 | 3 | 4 | 5 | 6;
+ face?: DiscoveryLocationId;
+ /** Exercise the previous blow army, distinct from the token's printed parent. */
+ shipForMaker?: boolean;
+ /** Real turn-one movement supplies a later split ordinary-reserve shipment. */
+ prepareImperialSplit?: boolean;
+};
+export type HomeworldClassicDiscoveryFixture = {
+ initial: Game; setup: Game; afterSetup: Game; game: Game;
+ actions: HomeworldClassicDiscoveryStep[]; staging: string[];
+ collector: string; fremen: string; token: DiscoveryOpaqueTokenId; face: DiscoveryLocationId;
+ parent: string; parentSector: number; amount: number; elite: number;
+ wormTerritory: string; wormSector: number;
+};
+export const homeworldClassicDiscoveryClean = (game: Game) =>
+ !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
+export const homeworldClassicDiscoveryPlayer = (game: Game, actor: string) => {
+ const player = game.players.find(player => player.id === actor);
+ assert.ok(player); return player;
+};
+
+/** Public owned continuations only. Storm dials use their original legal range;
+ * no phase clock, casualty, hand, balance or signed Discovery frame is staged. */
+export function nextHomeworldClassicDiscoveryStep(game: Game): HomeworldClassicDiscoveryStep {
+ if (!game.phaseOpening && !game.response) {
+ const decision = game.decision;
+ if (decision?.kind === 'discoveryEntry' || decision?.kind === 'greatMakerRide')
+ return { actor: decision.player, action: { type: 'decision', event: decision.event, accept: false } };
+ if (decision?.kind === 'greatMakerVote')
+ return { actor: decision.player, action: { type: 'decision', event: decision.event, yes: false } };
+ if (decision?.kind === 'wormRide')
+ return { actor: decision.player, action: { type: 'decision', accept: false } };
+ if (decision?.kind === 'wormProtection')
+ return { actor: decision.player, action: { type: 'decision', accept: true } };
+ }
+ const next = nextSpiceBankerIncomeNativeStep(game);
+ assert.ok(next, 'Original Homeworld/Discovery continuation needs an owned legal action.');
+ return next;
+}
+export function advanceHomeworldClassicDiscovery(state: Game, until: (game: Game) => boolean,
+ actions?: HomeworldClassicDiscoveryStep[]): Game {
+ let game = state;
+ for (let i = 0; !until(game) && i < 1800; i++) {
+ assert.notEqual(game.status, 'finished');
+ const next = nextHomeworldClassicDiscoveryStep(game);
+ actions?.push(structuredClone(next)); game = applyAction(game, next.actor, next.action);
+ }
+ assert.ok(until(game), 'Original Homeworld/Discovery programme did not reach its action window.');
+ return game;
+}
+export function homeworldClassicDiscoveryInventory(game: Game): void {
+ homeworldGameIntegrity(game);
+ validateDiscoveryState(game.discoveries!);
+ const spice = [...game.spiceDeck, ...game.spiceDiscard.flat(), ...(game.spiceResolution?.skipped ?? []),
+ ...(game.spiceSequence?.skipped ?? [])];
+ assert.equal(spice.filter(card => 'territory' in card && !!card.discovery).length, 6);
+ assert.equal(spice.filter(card => 'worm' in card && card.greatMaker).length, 1);
+ assert.equal(game.discoveries!.tokens.length, 8);
+ const cards = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand)];
+ assert.equal(cards.length, 33);
+ assert.equal(new Set(cards.map(card => card.id)).size, 33);
+ for (const player of game.players) if (player.elites) {
+ assert.equal(player.elites.reserves + player.elites.tanks +
+ Object.values(player.elites.forces).reduce((a, b) => a + b, 0), player.faction === 'fremen' ? 3 : 5);
+ }
+}
+function selectedTokenLottery(game: Game, face: DiscoveryLocationId, operation: () => T): T {
+ const pool = game.discoveries!.tokens.filter(token => token.status === 'supply' && token.type === DISCOVERY_TOKEN_BY_ID[face].type);
+ const index = pool.findIndex(token => token.face === face); assert.ok(index >= 0);
+ const native = crypto.getRandomValues.bind(crypto), descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
+ crypto.getRandomValues = (array: V): V => {
+ if (array instanceof Uint32Array && array.length === 1)
+ array[0] = Math.floor((index + 0.5) / pool.length * 0x100000000);
+ else Reflect.apply(native, crypto, [array]);
+ return array;
+ };
+ try { return operation(); }
+ finally {
+ if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
+ else Reflect.deleteProperty(crypto, 'getRandomValues');
+ }
+}
+function frontSpice(game: Game, choose: (card: Game['spiceDeck'][number]) => boolean, position: number): void {
+ const index = game.spiceDeck.findIndex((card, i) => i >= position && choose(card));
+ assert.ok(index >= position, 'The original unplayed physical Spice Card must remain available.');
+ game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
+}
+
+/** Root rulebook physical pp.22–23: reserves/Homeworlds, Emperor split sources,
+ * seven Spice Cards/eight tokens, actual printed blow and Collection reveal.
+ * Only original unplayed Spice order and the original supply lottery are selected. */
+export function createHomeworldClassicDiscoveryFixture(options: HomeworldClassicDiscoveryOptions = {}): HomeworldClassicDiscoveryFixture {
+ let game: Game;
+ if (options.initial) game = structuredClone(options.initial);
+ else {
+ const factions = ['emperor', 'fremen', 'guild', 'harkonnen', 'atreides', 'beneGesserit'] as const;
+ game = createGame('HWCLASSICDISCOVERY', newPlayer('emperor', 'Emperor', 'emperor'), options.advanced ?? false);
+ for (const faction of factions.slice(1, options.seats ?? 4)) joinGame(game, newPlayer(faction, faction, faction));
+ game = applyAction(game, game.host, { type: 'homeworlds', enabled: true });
+ game.discoveryEnabled = true;
+ }
+ assert.ok(game.status === 'lobby' || (game.status === 'setup' && game.turn === 1 && game.phase === 0));
+ assert.ok(classicHomeworldDiscoveryProfile(game));
+ const collector = game.players.find(player => player.faction === 'emperor')!.id;
+ const fremen = game.players.find(player => player.faction === 'fremen')!.id;
+ assert.ok(collector && fremen);
+ const actions: HomeworldClassicDiscoveryStep[] = [];
+ if (game.status === 'lobby') for (const player of game.players) if (!player.ready) {
+ const next: HomeworldClassicDiscoveryStep = { actor: player.id, action: { type: 'ready' } };
+ actions.push(next); game = applyAction(game, next.actor, next.action);
+ }
+ const initial = structuredClone(game);
+ if (game.status === 'lobby') game = initializeDiscoveryGameForAudit(game);
+ const setup = structuredClone(game);
+ game = advanceHomeworldClassicDiscovery(game, state => state.status === 'playing', actions);
+ const afterSetup = structuredClone(game), face = options.face ?? 'cistern';
+ frontSpice(game, card => 'territory' in card && card.discovery === 'discovery-hagga-basin', 0);
+ // Advanced consumes two piles; Basic consumes one. The next original Great
+ // Maker therefore meets pile zero's original Hagga Basin blow on turn two.
+ if (game.advanced) frontSpice(game, card => 'territory' in card && !card.discovery && card.territory !== 'hagga_basin', 1);
+ const nextTurnIndex = game.advanced ? 2 : 1;
+ frontSpice(game, card => 'worm' in card && !!card.greatMaker, nextTurnIndex);
+ for (let position = nextTurnIndex + 1; position < nextTurnIndex + (game.advanced ? 3 : 2); position++)
+ frontSpice(game, card => 'territory' in card && !card.discovery && card.territory !== 'hagga_basin', position);
+ const staging = ['Reordered conserved original unplayed Spice Cards for the printed Hagga Basin Discovery blow and turn-two Great Maker; original starting hands/Treachery deck untouched.',
+ `Selected ${face} through the actual original Hiereg supply-token lottery; no reveal, force position or Discovery continuation injected.`];
+ game = advanceHomeworldClassicDiscovery(game, state => state.phase === 1 && homeworldClassicDiscoveryClean(state), actions);
+ game = selectedTokenLottery(game, face, () => advanceHomeworldClassicDiscovery(game,
+ state => state.discoveries!.tokens.some(token => token.face === face && token.status === 'placed'), actions));
+ game = advanceHomeworldClassicDiscovery(game, state => state.phase === 5 && state.active === collector && homeworldClassicDiscoveryClean(state), actions);
+ const placement = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'];
+ const blow = DISCOVERY_SPICE_CARDS.find(card => card.discovery === 'discovery-hagga-basin')!;
+ const destination = options.shipForMaker ? blow : placement;
+ const amount = game.advanced ? 3 : 2, elite = game.advanced ? 1 : 0;
+ const sources = nativeShipmentSources(viewGame(game, collector), amount, elite); assert.ok(sources);
+ const shipment: HomeworldClassicDiscoveryStep = { actor: collector, action: { type: 'ship',
+ territory: destination.territory, sector: destination.sector, amount, elite, allyPayment: 0, homeworldSources: sources } };
+ actions.push(shipment); game = applyAction(game, collector, shipment.action);
+ if (game.advanced && options.prepareImperialSplit) {
+ game = advanceHomeworldClassicDiscovery(game, state => !state.pendingShipment && homeworldClassicDiscoveryClean(state), actions);
+ const movement = viewGame(game, collector).homeworldMove!;
+ const transfer: HomeworldClassicDiscoveryStep = { actor: collector, action: {
+ type: 'emperorHomeworldMove', event: movement.event, origin: 'homeworld:emperor', normal: 1, elite: 0,
+ } };
+ actions.push(transfer); game = applyAction(game, collector, transfer.action);
+ }
+ game = advanceHomeworldClassicDiscovery(game, state => state.phase === 7 && homeworldClassicDiscoveryClean(state), actions);
+ const token = game.discoveries!.tokens.find(token => token.face === face)!;
+ assert.equal(game.turn, 1); homeworldClassicDiscoveryInventory(game);
+ return { initial, setup, afterSetup, game, actions, staging, collector, fremen, token: token.id, face,
+ parent: placement.territory, parentSector: placement.sector, amount, elite,
+ wormTerritory: blow.territory, wormSector: blow.sector };
+}
+export function revealHomeworldClassicDiscovery(fixture: HomeworldClassicDiscoveryFixture): Game {
+ let game = fixture.game;
+ if (viewGame(game, fixture.collector).discoveries!.canInspect.includes(fixture.token))
+ game = applyAction(game, fixture.collector, { type: 'discovery', token: fixture.token, reveal: false });
+ assert.ok(viewGame(game, fixture.collector).discoveries!.canReveal.includes(fixture.token));
+ return applyAction(game, fixture.collector, { type: 'discovery', token: fixture.token, reveal: true });
+}
+export function homeworldClassicDiscoveryEntryWindow(fixture: HomeworldClassicDiscoveryFixture): Game {
+ return advanceHomeworldClassicDiscovery(revealHomeworldClassicDiscovery(fixture), game =>
+ game.turn === 2 && game.decision?.kind === 'discoveryEntry' && game.decision.player === fixture.collector);
+}
+export function enterHomeworldClassicDiscovery(fixture: HomeworldClassicDiscoveryFixture): Game {
+ const game = homeworldClassicDiscoveryEntryWindow(fixture), view = viewGame(game, fixture.collector);
+ const action = discoveryEntryMoveAction(view, view.discoveryEntry!.sources); assert.ok(action);
+ return applyAction(game, fixture.collector, action);
+}
+export function homeworldClassicDiscoveryShipmentWindow(state: Game, owner: string): Game {
+ return advanceHomeworldClassicDiscovery(state, game => game.turn === 2 && game.phase === 5 &&
+ game.active === owner && homeworldClassicDiscoveryClean(game));
+}
+export function settleHomeworldClassicDiscoveryArrival(state: Game): Game {
+ return advanceHomeworldClassicDiscovery(state, game => !game.pendingShipment && homeworldClassicDiscoveryClean(game));
+}
+export function homeworldClassicGreatMakerVoteWindow(fixture: HomeworldClassicDiscoveryFixture): Game {
+ return advanceHomeworldClassicDiscovery(fixture.game, game => game.decision?.kind === 'greatMakerVote');
+}
diff --git a/tests/fixture-homeworld-classic-skills.ts b/tests/fixture-homeworld-classic-skills.ts
new file mode 100644
index 00000000..c1adcce4
--- /dev/null
+++ b/tests/fixture-homeworld-classic-skills.ts
@@ -0,0 +1,286 @@
+import assert from 'node:assert/strict';
+import { applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game, type Player } from '../game/engine';
+import { botActions } from '../game/bots';
+import type { Difficulty } from '../game/bot-profiles';
+import { territory } from '../game/board';
+import { treacheryDeck } from '../game/cards';
+import type { FactionId } from '../game/catalog';
+import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
+import { validateLeaderSkills } from '../game/leader-skills';
+import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
+import { homeworldForceGroups } from '../game/homeworld-custody';
+import { quoteNativeReserveWithdrawal } from '../game/homeworld-native-reserves';
+import { homeworldCombatLocation } from '../game/homeworld-combat';
+import { quoteBattleResolution, type ResolutionCombatant } from '../game/battle-resolution-quote';
+import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
+
+export type ClassicHomeworldSkillsStep = { actor: string; action: Action };
+export type ClassicHomeworldSkillsCase = 'native-bonus' | 'visitor-bonus' | 'native-death' | 'visitor-death' | 'native-suk' | 'visitor-suk' | 'arrakis-suk' | 'smuggler-payment';
+export type ClassicHomeworldSkillsSetup = {
+ initial: Game;
+ offered: Game;
+ afterSetup: Game;
+ actions: ClassicHomeworldSkillsStep[];
+ staging: string[];
+};
+export type ClassicHomeworldSkillsFixture = ClassicHomeworldSkillsSetup & {
+ kind: ClassicHomeworldSkillsCase;
+ beforeShipment: Game | null;
+ shipment: ClassicHomeworldSkillsStep | null;
+ afterShipment: Game | null;
+ beforeBattle: Game | null;
+ game: Game;
+ owner: string;
+ opponent: string;
+ trainer: string;
+ territory: string;
+ plans: ClassicHomeworldSkillsStep[];
+};
+export const classicHomeworldSkillsPlayer = (game: Game, id: string): Player => {
+ const player = game.players.find(p => p.id === id); assert.ok(player); return player;
+};
+const sum = (values: Record) => Object.values(values).reduce((a, b) => a + b, 0);
+const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
+
+export function assertClassicHomeworldSkillsCustody(game: Game): void {
+ homeworldGameIntegrity(game);
+ validateLeaderSkills(game.leaderSkills!, game.players);
+ const homes = game.homeworlds?.custody ? homeworldForceGroups(homeworldContext(game), game.homeworlds.custody) : [];
+ for (const player of game.players) {
+ const visitors = homes.filter(home => home.native !== player.id).map(home => home.forces[player.id] ?? { normal: 0, elite: 0 });
+ assert.equal(player.reserves + player.tanks + sum(player.forces) + visitors.reduce((n, pool) => n + pool.normal + pool.elite, 0), 20, player.faction);
+ if (player.elites) assert.equal(player.elites.reserves + player.elites.tanks + sum(player.elites.forces) + visitors.reduce((n, pool) => n + pool.elite, 0), player.faction === 'emperor' ? 5 : 3, player.faction);
+ }
+ const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand), ...(game.auction?.cards.slice(game.auction.index) ?? [])];
+ assert.deepEqual(cards.map(c => c.id).sort(), treacheryDeck([]).map(c => c.id).sort());
+}
+
+/** Compute actual Fisher-Yates draws, not an assignment or held-card injection. */
+function permutationDraws(source: readonly T[], desired: readonly T[]): number[] {
+ const current = [...source], draws: number[] = [];
+ for (let i = current.length - 1; i > 0; i--) {
+ const j = current.indexOf(desired[i]); assert.ok(j >= 0 && j <= i);
+ draws.push(Math.floor(((j + 0.5) / (i + 1)) * 4294967296));
+ [current[i], current[j]] = [current[j], current[i]];
+ }
+ assert.deepEqual(current, desired); return draws;
+}
+function originalLottery(draws: readonly number[], run: () => T): T {
+ const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
+ const native = crypto.getRandomValues.bind(crypto); let index = 0;
+ crypto.getRandomValues = (array: V): V => {
+ if (array instanceof Uint32Array && array.length === 1) array[0] = draws[index++] ?? 0xffffffff;
+ else Reflect.apply(native, crypto, [array]);
+ return array;
+ };
+ try { return run(); }
+ finally {
+ if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
+ else Reflect.deleteProperty(crypto, 'getRandomValues');
+ }
+}
+export function classicHomeworldSkillsBotOffers(game: Game, actor: string, difficulty: Difficulty): Action[] {
+ const view = viewGame(game, actor);
+ view.players.find(p => p.id === actor)!.bot = difficulty;
+ return botActions(view);
+}
+
+/** Original all-fourteen setup. Only original skill and Treachery shuffle draws
+ * are controlled. Starting hands, wallets, offers and leader custody are dealt
+ * by the original initializer and selected with real setup actions. */
+export function createClassicHomeworldSkillsSetup(options: {
+ advanced?: boolean;
+ roster?: readonly FactionId[];
+ initial?: Game;
+ skill?: LeaderSkillId;
+} = {}): ClassicHomeworldSkillsSetup {
+ const roster = options.roster ?? ['emperor', 'guild', 'atreides', 'harkonnen'];
+ let game = options.initial ? structuredClone(options.initial)
+ : createGame('CLASSICHWSKILLS', newPlayer(roster[0], roster[0], roster[0]), options.advanced ?? false);
+ if (!options.initial) for (const faction of roster.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
+ assert.ok(game.status === 'lobby' || (game.status === 'setup' && game.leaderSkills && game.homeworlds && game.turn === 1 && game.phase === 0),
+ 'Continue only an original fresh lobby or its existing Homeworld Skills setup.');
+ if (game.status === 'lobby') {
+ if (!game.homeworlds) game = applyAction(game, game.host, { type: 'homeworlds', enabled: true });
+ for (const p of game.players) if (!p.ready) game = applyAction(game, p.id, { type: 'ready' });
+ }
+ const initial = structuredClone(game), actions: ClassicHomeworldSkillsStep[] = [];
+ const owner = game.players[0].id, skill = options.skill ?? 'mentat';
+ const canonical = LEADER_SKILL_CARDS.map(c => c.id), requested: LeaderSkillId[] = [skill];
+ for (const p of game.players.slice(1)) {
+ const preferred: LeaderSkillId = p.faction === 'atreides' ? 'bureaucrat' :
+ p.faction === 'harkonnen' ? 'smuggler' : p.faction === 'guild' ? 'swordmaster-of-ginaz' :
+ p.faction === 'fremen' ? 'killer-medic' : 'prana-bindu-adept';
+ const selected = requested.includes(preferred)
+ ? (['warmaster', 'planetologist', 'diplomat', 'master-of-assassins', 'sandmaster'] as const).find(id => !requested.includes(id))!
+ : preferred;
+ assert.ok(selected); requested.push(selected);
+ }
+ const rest = canonical.filter(id => !requested.includes(id));
+ const desired = requested.flatMap(id => [id, rest.shift()!]).concat(rest);
+ const cards = treacheryDeck([]), prefix: string[] = [];
+ for (const [index, player] of game.players.entries()) {
+ const kind = index === 0 ? 'worthless' : index === 1 ? 'projectile' : 'snooper';
+ const card = cards.find(c => c.kind === kind && !prefix.includes(c.id)) ?? cards.find(c => !prefix.includes(c.id))!;
+ prefix.push(card.id);
+ if (player.faction === 'harkonnen') prefix.push(cards.find(c => !prefix.includes(c.id))!.id);
+ }
+ const deckOrder = [...prefix, ...cards.map(c => c.id).filter(id => !prefix.includes(id))];
+ if (game.status === 'lobby') game = originalLottery(
+ [...permutationDraws(canonical, desired), ...permutationDraws(cards.map(c => c.id), deckOrder)],
+ () => initializeLeaderSkillsGameForAudit(game));
+ let offered: Game | null = null;
+ for (let count = 0; game.status === 'setup' && count < 200; count++) {
+ let next: ClassicHomeworldSkillsStep;
+ if (game.setupStage === 'leaderSkills') {
+ offered ??= structuredClone(game);
+ const actor = Object.keys(game.leaderSkills!.offers)[0];
+ const view = viewGame(game, actor).leaderSkills!, offer = view.offer!;
+ const selected = requested[game.players.findIndex(p => p.id === actor)];
+ assert.ok(offer.cards.includes(selected));
+ assert.ok(!view.unavailableSkills?.[selected]);
+ const leaders = view.eligibleLeaders.map(l => classicHomeworldSkillsPlayer(game, actor).leaders.find(disc => disc.id === l.id)!)
+ .sort((a, b) => b.strength - a.strength);
+ assert.ok(leaders[0]);
+ next = { actor, action: { type: 'leaderSkill', event: offer.event, skill: selected, leader: leaders[0].id } };
+ } else {
+ const candidate = nextStrongholdFactionsNativeStep(game); assert.ok(candidate); next = candidate;
+ }
+ actions.push(structuredClone(next)); game = applyAction(game, next.actor, next.action);
+ }
+ assert.ok(offered); assert.equal(game.status, 'playing');
+ assert.ok(game.leaderSkills!.assignments.some(a => a.owner === owner && a.skill === skill));
+ assertClassicHomeworldSkillsCustody(game);
+ return { initial, offered, afterSetup: structuredClone(game), actions,
+ staging: ['Controlled the original skill/Treachery lottery before any component was dealt; all starting hands and skill assignments are real setup selections.'] };
+}
+
+export function nextClassicHomeworldSkillsStep(game: Game, hideOwner?: string): ClassicHomeworldSkillsStep | null {
+ const decision = game.decision;
+ if (decision?.kind === 'leaderSkillVisibility') return { actor: decision.player, action: {
+ type: 'leaderSkillVisibility', event: decision.event, hide: decision.player === hideOwner,
+ } };
+ if (decision?.kind === 'mentatQuestion') return { actor: decision.player, action: { type: 'decision', event: decision.event, decline: true } };
+ if (decision?.kind === 'rihani') return { actor: decision.player, action: { type: 'decision', event: decision.event, draw: false } };
+ if (decision?.kind === 'bureaucratPayment') return { actor: decision.player, action: { type: 'decision', event: game.bureaucratPaymentEvent, redirect: false } };
+ if (decision?.kind === 'sukRescue') return null;
+ if (decision?.kind === 'battleLosses' && game.advanced && game.pendingSukRescue) {
+ const elite = Math.max(...decision.options.map(option => option.elite));
+ return { actor: decision.player, action: { type: 'decision',
+ choice: decision.options.findIndex(option => option.elite === elite) } };
+ }
+ if (decision?.kind === 'homeworldDefense') return { actor: decision.player, action: { type: 'decision', event: decision.event, use: false } };
+ return nextStrongholdFactionsNativeStep(game);
+}
+export function advanceClassicHomeworldSkills(state: Game, until: (game: Game) => boolean,
+ actions?: ClassicHomeworldSkillsStep[], hideOwner?: string): Game {
+ let game = state;
+ for (let count = 0; count < 1600; count++) {
+ if (until(game)) return game;
+ const next = nextClassicHomeworldSkillsStep(game, hideOwner); assert.ok(next, 'A real human Battle Plan or Suk choice is required');
+ actions?.push(structuredClone(next)); game = applyAction(game, next.actor, next.action);
+ }
+ throw Error('Original classic Homeworld Skills programme did not reach its requested boundary');
+}
+
+function shipHome(game: Game, actor: string, destination: string, normal: number, elite: number): ClassicHomeworldSkillsStep {
+ const player = classicHomeworldSkillsPlayer(game, actor);
+ const sources = player.faction === 'emperor' && game.advanced ? {
+ 'homeworld:emperor': { normal, elite: 0 }, 'homeworld:emperor:salusa': { normal: 0, elite },
+ } : { [`homeworld:${player.faction}`]: { normal, elite } };
+ return { actor, action: { type: 'homeworldShip', event: viewGame(game, actor).homeworldShipment!.event, destination, sources } };
+}
+/** Genuine phase sequence and invasion shipment. The Arrakis case alone uses a
+ * labeled conserved later board position; it never fabricates a battle window,
+ * casualty, death, invoice, skill assignment or held card. */
+export function createClassicHomeworldSkillsFixture(options: { kind?: ClassicHomeworldSkillsCase; advanced?: boolean; initial?: Game } = {}): ClassicHomeworldSkillsFixture {
+ const kind = options.kind ?? 'native-bonus', advanced = options.advanced ?? false;
+ const skill = kind.includes('suk') ? 'suk-graduate' : kind === 'smuggler-payment' ? 'smuggler' : 'mentat';
+ const roster: FactionId[] = ['emperor', 'guild', 'atreides', 'harkonnen'];
+ const setup = createClassicHomeworldSkillsSetup({ advanced, initial: options.initial, roster, skill });
+ let game = advanceClassicHomeworldSkills(setup.afterSetup, g => g.phase === 5 && clean(g), setup.actions);
+ const owner = game.players[0].id, opponent = game.players[1].id;
+ const trainer = game.leaderSkills!.assignments.find(a => a.owner === owner)!.leader;
+ const visitor = kind.startsWith('visitor');
+ const location = kind === 'arrakis-suk' ? 'false_wall_south' : kind === 'smuggler-payment' ? 'sietch_tabr' :
+ visitor ? 'homeworld:guild' : kind === 'native-suk' && advanced ? 'homeworld:emperor:salusa' : 'homeworld:emperor';
+ let beforeShipment: Game | null = null, shipment: ClassicHomeworldSkillsStep | null = null, afterShipment: Game | null = null;
+ if (kind === 'arrakis-suk') {
+ const count = 8, elite = advanced ? 2 : 0;
+ const sources = advanced ? { 'homeworld:emperor': { normal: count - elite, elite: 0 }, 'homeworld:emperor:salusa': { normal: 0, elite } } : undefined;
+ const quote = quoteNativeReserveWithdrawal(homeworldContext(game), game.homeworlds!.custody!, owner, { normal: count - elite, elite }, sources);
+ game.homeworlds!.custody = quote.state;
+ for (const update of quote.players) { const p = classicHomeworldSkillsPlayer(game, update.id); p.reserves = update.reserves; if (p.elites) p.elites.reserves = update.eliteReserves; }
+ const key = `${location}:${territory(location).sectors[0]}`;
+ classicHomeworldSkillsPlayer(game, owner).forces[key] = count;
+ if (elite) classicHomeworldSkillsPlayer(game, owner).elites!.forces[key] = elite;
+ const other = classicHomeworldSkillsPlayer(game, opponent);
+ const total = sum(other.forces); other.forces = { [key]: total };
+ setup.staging.push('Conserved later Arrakis position: eight Emperor counters withdrew from their real native homes; the original Guild board army moved to the same desert. No shipment or battle receipt is claimed for these placements.');
+ assertClassicHomeworldSkillsCustody(game);
+ } else {
+ const actor = kind === 'smuggler-payment' || visitor ? owner : opponent;
+ game = advanceClassicHomeworldSkills(game, g => g.phase === 5 && clean(g) && g.active === actor, setup.actions);
+ beforeShipment = structuredClone(game);
+ shipment = kind === 'smuggler-payment' ? { actor, action: { type: 'ship', territory: location,
+ sector: territory(location).sectors[0], amount: 6, elite: advanced ? 2 : 0, smuggler: true,
+ homeworldSources: advanced ? { 'homeworld:emperor': { normal: 4, elite: 0 }, 'homeworld:emperor:salusa': { normal: 0, elite: 2 } } : undefined } }
+ : shipHome(game, actor, location, visitor ? 4 : 3, visitor && advanced ? 1 : 0);
+ setup.actions.push(structuredClone(shipment)); game = applyAction(game, actor, shipment.action);
+ if (game.decision?.kind === 'guildShipment') {
+ const allow = { actor: game.decision.player, action: { type: 'decision', allow: true } };
+ setup.actions.push(allow); game = applyAction(game, allow.actor, allow.action);
+ }
+ afterShipment = structuredClone(game);
+ if (kind === 'smuggler-payment') return { ...setup, kind, beforeShipment, shipment, afterShipment, beforeBattle: null, game, owner, opponent, trainer, territory: location, plans: [] };
+ game = advanceClassicHomeworldSkills(game, g => g.phase === 5 && clean(g), setup.actions);
+ }
+ game = advanceClassicHomeworldSkills(game, g => g.phase === 6 && clean(g), setup.actions);
+ const beforeBattle = structuredClone(game), actor = game.active!;
+ assert.ok(actor === owner || actor === opponent);
+ const choose = { actor, action: { type: 'chooseBattle', territory: location, target: actor === owner ? opponent : owner } };
+ setup.actions.push(choose); game = applyAction(game, actor, choose.action);
+ game = advanceClassicHomeworldSkills(game, g => !!g.battle && clean(g) && !g.battle.preparation && g.battle.preLeader?.closed !== false, setup.actions, owner);
+ const enemy = classicHomeworldSkillsPlayer(game, opponent);
+ const trained = new Set(game.leaderSkills!.assignments.map(a => a.leader));
+ const leader = enemy.leaders.filter(l => !l.dead && !trained.has(l.id)).sort((a, b) => a.strength - b.strength)[0]; assert.ok(leader);
+ const weapon = kind.endsWith('death') ? enemy.hand.find(c => c.kind === 'projectile')?.id : undefined;
+ if (kind.endsWith('death')) assert.ok(weapon, 'Actual original opponent starting hand holds the lethal weapon');
+ const dial = kind.endsWith('death') ? 0 : kind === 'native-suk' && advanced ? 6 : 4;
+ const plans: ClassicHomeworldSkillsStep[] = [
+ { actor: owner, action: { type: 'battlePlan', leader: trainer, dial, support: advanced && dial && kind !== 'native-suk' ? 2 : 0 } },
+ { actor: opponent, action: { type: 'battlePlan', leader: leader.id, dial: 0, weapon } },
+ ];
+ assertClassicHomeworldSkillsCustody(game);
+ return { ...setup, kind, beforeShipment, shipment, afterShipment, beforeBattle, game, owner, opponent, trainer, territory: location, plans };
+}
+export function revealClassicHomeworldSkillsBattle(fixture: ClassicHomeworldSkillsFixture): Game {
+ let game = structuredClone(fixture.game);
+ for (const step of fixture.plans) {
+ game = applyAction(game, step.actor, step.action);
+ if (!game.battle?.revealed) game = advanceClassicHomeworldSkills(game, g => clean(g) && !!g.battle && !g.battle.preparation, undefined, fixture.owner);
+ }
+ assert.ok(game.battle?.revealed); return game;
+}
+export function settleClassicHomeworldSkillsBattle(state: Game, stop: 'suk' | 'complete' = 'complete'): Game {
+ return advanceClassicHomeworldSkills(state, game => stop === 'suk' && game.decision?.kind === 'sukRescue' ||
+ !game.battle && clean(game) && !game.pendingTreacheryDiscard);
+}
+export function quoteClassicHomeworldSkillsBattle(game: Game) {
+ const battle = game.battle; assert.ok(battle?.revealed);
+ const side = (actor: string): ResolutionCombatant => {
+ const player = classicHomeworldSkillsPlayer(game, actor), view = viewGame(game, actor);
+ return { id: actor, faction: player.faction, spice: player.spice, hand: player.hand,
+ plan: battle.plans[actor], leader: player.leaders.find(l => l.id === battle.plans[actor].leader),
+ forces: view.battle!.ownForces!, leaderSkills: view.leaderSkills!.assignments.filter(a => a.controller === actor),
+ occupiedStrongholds: new Set(Object.keys(player.forces).map(key => key.split(':')[0]).filter(id => territory(id).type === 'stronghold')).size };
+ };
+ const home = battle.territory.startsWith('homeworld:') ? homeworldCombatLocation({ ...homeworldContext(game), order: game.order }, game.homeworlds!.custody!, battle.territory) : null;
+ return quoteBattleResolution({ advanced: game.advanced, typedCasualties: true, turn: game.turn, territory: battle.territory,
+ aggressor: viewGame(game, game.host).battle!.aggressor,
+ ...(home ? { homeworld: { native: home.native, card: home.card, side: home.side, nativeForces: home.forces[home.native] } } : {}),
+ attacker: side(battle.attacker), defender: side(battle.defender),
+ voters: viewGame(game, game.host).battle!.traitorVoters.map(id => ({ id, beneficiary: id, called: battle.traitorCalls[id] ?? false, traitors: classicHomeworldSkillsPlayer(game, id).traitors })),
+ participants: game.players, physicalCards: [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand)],
+ pendingAuditorPresent: false, pendingRetentionPresent: false });
+}
diff --git a/tests/fixture-spice-banker-income.ts b/tests/fixture-spice-banker-income.ts
index e68fbcd3..244940d8 100644
--- a/tests/fixture-spice-banker-income.ts
+++ b/tests/fixture-spice-banker-income.ts
@@ -37,7 +37,7 @@ export function nextSpiceBankerIncomeNativeStep(game:Game):SpiceBankerIncomeNati
const voter=viewGame(game,game.host).battle!.traitorVoters.find(id=>game.battle!.traitorCalls[id]===undefined);
if(voter) return {actor:voter,action:{type:'traitorCall',call:false}};
}
- if(game.phase!==6||!game.active) {
+ if([1,2,4,7,8].includes(game.phase)||(game.phase===0&&game.stormPending!==null)) {
const seat=game.players.find(p=>!game.ready.includes(p.id));
if(seat) return {actor:seat.id,action:{type:'ready'}};
}
diff --git a/tests/fixture-stronghold-factions.ts b/tests/fixture-stronghold-factions.ts
index f5de9c8e..b41d7c08 100644
--- a/tests/fixture-stronghold-factions.ts
+++ b/tests/fixture-stronghold-factions.ts
@@ -124,7 +124,7 @@ export function nextStrongholdFactionsNativeStep(game: Game): StrongholdFactions
}
if (game.phase === 3 && game.auction) return { actor: game.auction.active, action: { type: 'passBid' } };
if (game.phase === 5) return { actor: game.active!, action: { type: 'endMovement' } };
- if (game.phase !== 6 || !game.active) {
+ if ([1, 2, 4, 7, 8].includes(game.phase) || (game.phase === 0 && game.stormPending !== null)) {
const actor = game.players.find(p => !game.ready.includes(p.id))?.id;
if (actor) return { actor, action: { type: 'ready' } };
}
diff --git a/tests/homeworld-classic-discovery-runtime.test.ts b/tests/homeworld-classic-discovery-runtime.test.ts
new file mode 100644
index 00000000..394e9e7d
--- /dev/null
+++ b/tests/homeworld-classic-discovery-runtime.test.ts
@@ -0,0 +1,232 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { applyAction, viewGame, type Action, type Game } from '../game/engine';
+import { botActions } from '../game/bots';
+import { DIFFICULTIES } from '../game/bot-profiles';
+import { gameTerritories, validGameLocation } from '../game/board';
+import { discoveryEntryDeclineAction, discoveryEntryMoveAction } from '../game/discovery-entry-options';
+import { greatMakerRideAction } from '../game/great-maker-options';
+import { nativeShipmentSources } from '../game/homeworld-options';
+import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
+import { quoteSpiceCollection } from '../game/board-resolution-quote';
+import {
+ advanceHomeworldClassicDiscovery, createHomeworldClassicDiscoveryFixture,
+ enterHomeworldClassicDiscovery, homeworldClassicDiscoveryClean,
+ homeworldClassicDiscoveryEntryWindow, homeworldClassicDiscoveryInventory,
+ homeworldClassicDiscoveryPlayer, homeworldClassicDiscoveryShipmentWindow,
+ homeworldClassicGreatMakerVoteWindow, settleHomeworldClassicDiscoveryArrival,
+} from './fixture-homeworld-classic-discovery';
+
+/** Source: authorized root UNOFFICIAL_Revised_Dune_Rulebook_v.2.3_web.pdf,
+ * physical p.22 Homeworlds (not territories; normal battle modifications;
+ * Emperor's split shipment), physical p.23 Discovery Tokens/Great Maker
+ * (7 cards/8 tokens; ordinary worm; storm-order majority; native reserve ride;
+ * parent-board-only next-turn free entry; normal paid shipment at 1/force).
+ * These programmes select original unplayed Spice positions/token lottery,
+ * not held cards, fake casualties, free-entry frames or source receipts. */
+function legalPolicies(game: Game, actor: string): void {
+ for (const difficulty of DIFFICULTIES) {
+ const view = viewGame(game, actor);
+ view.players.find(player => player.id === actor)!.bot = difficulty;
+ const actions = botActions(view);
+ assert.ok(actions.length, `${difficulty} needs an owned minimal legal continuation.`);
+ const done = applyAction(structuredClone(game), actor, actions[0]);
+ homeworldClassicDiscoveryInventory(done);
+ }
+}
+function reject(game: Game, actor: string, action: Action): void {
+ const before = structuredClone(game);
+ assert.throws(() => applyAction(game, actor, action));
+ assert.deepEqual(game, before);
+}
+
+void test('original Homeworld/Discovery setup admits Basic/Advanced two through six base seats with all physical cards and typed forces', () => {
+ for (const advanced of [false, true]) for (const seats of [2, 3, 4, 5, 6] as const) {
+ const fixture = createHomeworldClassicDiscoveryFixture({ advanced, seats });
+ assert.equal(fixture.game.players.length, seats);
+ assert.equal(fixture.game.turn, 1); assert.equal(fixture.game.phase, 7);
+ homeworldClassicDiscoveryInventory(fixture.setup);
+ homeworldClassicDiscoveryInventory(fixture.afterSetup);
+ homeworldClassicDiscoveryInventory(fixture.game);
+ }
+});
+
+void test('Great Maker devours the actual previous blow army before storm-order voting, with majority and tie outcomes and typed native ride', () => {
+ for (const advanced of [false, true]) for (const majority of [false, true]) {
+ const fixture = createHomeworldClassicDiscoveryFixture({ advanced, shipForMaker: true });
+ // Explicit later conserved position, not a shipment/history receipt: keep
+ // native board counters beside the actually shipped Emperor army to exercise
+ // the printed ordinary-worm Fremen survival before the reserve-only ride.
+ const nativePosition = homeworldClassicDiscoveryPlayer(fixture.game, fixture.fremen);
+ const parentKey = `${fixture.wormTerritory}:${fixture.wormSector}`, positionedElite = advanced ? 1 : 0;
+ assert.ok(nativePosition.reserves - (nativePosition.elites?.reserves ?? 0) >= 2 - positionedElite);
+ nativePosition.reserves -= 2;
+ nativePosition.forces[parentKey] = (nativePosition.forces[parentKey] ?? 0) + 2;
+ if (positionedElite) {
+ nativePosition.elites!.reserves -= positionedElite;
+ nativePosition.elites!.forces[parentKey] = (nativePosition.elites!.forces[parentKey] ?? 0) + positionedElite;
+ }
+ fixture.staging.push(`Conserved two original Fremen native-reserve counters (${positionedElite} elite) into the previous blow sector after turn-one Collection opened; no casualty, shipment or history fabricated.`);
+ const nativeBeforeWorm = structuredClone(nativePosition);
+ homeworldClassicDiscoveryInventory(fixture.game);
+ const originalArmy = homeworldClassicDiscoveryPlayer(fixture.game, fixture.collector);
+ let game = homeworldClassicGreatMakerVoteWindow(fixture);
+ assert.equal(game.turn, 2); assert.equal(game.phase, 1);
+ assert.equal(game.greatMaker!.territory, fixture.wormTerritory);
+ const devoured = homeworldClassicDiscoveryPlayer(game, fixture.collector);
+ assert.equal(devoured.forces[parentKey] ?? 0, 0);
+ assert.equal(devoured.tanks, originalArmy.tanks + fixture.amount);
+ assert.equal(devoured.elites?.tanks ?? 0, (originalArmy.elites?.tanks ?? 0) + fixture.elite);
+ assert.equal(devoured.reserves, originalArmy.reserves);
+ assert.equal(game.discoveries!.tokens.find(token => token.id === fixture.token)!.revealedTurn, null);
+ const order = [...game.order];
+ for (let index = 0; index < order.length; index++) {
+ assert.equal(game.decision?.kind, 'greatMakerVote'); assert.equal(game.decision!.player, order[index]);
+ legalPolicies(game, order[index]);
+ const vote: Action = { type: 'decision', event: game.decision!.event, yes: index < (majority ? 3 : 2) };
+ reject(game, order[(index + 1) % order.length], vote);
+ game = applyAction(game, order[index], vote);
+ }
+ const survived = homeworldClassicDiscoveryPlayer(game, fixture.fremen);
+ assert.equal(survived.forces[parentKey], nativeBeforeWorm.forces[parentKey]);
+ assert.equal(survived.elites?.forces[parentKey] ?? 0, nativeBeforeWorm.elites?.forces[parentKey] ?? 0);
+ assert.equal(survived.tanks, nativeBeforeWorm.tanks);
+ assert.equal(survived.reserves, nativeBeforeWorm.reserves);
+ assert.equal(game.nexus, majority, 'Two of four is not a majority; three is.');
+ assert.equal(game.decision?.kind, 'greatMakerRide'); assert.equal(game.decision!.player, fixture.fremen);
+ legalPolicies(game, fixture.fremen);
+ const before = structuredClone(homeworldClassicDiscoveryPlayer(game, fixture.fremen));
+ const view = viewGame(game, fixture.fremen), destinations = view.greatMaker!.ride!.destinations;
+ assert.ok(destinations.every(destination => !destination.territory.startsWith('homeworld:')));
+ const target = destinations.find(destination => destination.territory === 'polar_sink')!; assert.ok(target);
+ const elite = advanced ? 1 : 0, action = greatMakerRideAction(view, target.territory, target.sector, 2, elite);
+ assert.ok(action);
+ assert.equal(greatMakerRideAction(view, 'homeworld:fremen', 0, 2, elite), null);
+ reject(game, fixture.fremen, { ...action, territory: 'homeworld:fremen', sector: 0 });
+ reject(game, fixture.fremen, { ...action, territory: fixture.parent, sector: game.storm });
+ reject(game, fixture.fremen, { ...action, elite: 4 });
+ game = applyAction(game, fixture.fremen, action);
+ const after = homeworldClassicDiscoveryPlayer(game, fixture.fremen);
+ assert.equal(after.reserves, before.reserves - 2);
+ assert.equal(after.elites?.reserves ?? 0, (before.elites?.reserves ?? 0) - elite);
+ assert.equal(after.forces[`${target.territory}:${target.sector}`], (before.forces[`${target.territory}:${target.sector}`] ?? 0) + 2);
+ assert.equal(after.elites?.forces[`${target.territory}:${target.sector}`] ?? 0,
+ (before.elites?.forces[`${target.territory}:${target.sector}`] ?? 0) + elite);
+ assert.equal(after.spice, before.spice); assert.equal(after.moved, before.moved); assert.equal(after.shipped, before.shipped);
+ const home = viewGame(game, fixture.fremen).homeworlds!.worlds!.find(world => world.native === fixture.fremen)!;
+ assert.equal(home.forces[fixture.fremen].normal + home.forces[fixture.fremen].elite, after.reserves);
+ assert.equal(home.forces[fixture.fremen].elite, after.elites?.reserves ?? 0);
+ homeworldClassicDiscoveryInventory(game);
+ }
+});
+
+void test('actual next-turn free entry moves only the parent board ordinary/elite groups and leaves native pools, spice and normal allowances untouched', () => {
+ for (const advanced of [false, true]) {
+ const fixture = createHomeworldClassicDiscoveryFixture({ advanced });
+ const game = homeworldClassicDiscoveryEntryWindow(fixture), view = viewGame(game, fixture.collector);
+ assert.equal(game.turn, 2); assert.equal(game.phase, 0);
+ assert.equal(homeworldClassicDiscoveryPlayer(game, fixture.collector).forces[`${fixture.face}:0`] ?? 0, 0);
+ const sources = view.discoveryEntry!.sources;
+ assert.deepEqual(sources, [{ source: `${fixture.parent}:${fixture.parentSector}`, normal: fixture.amount - fixture.elite, elite: fixture.elite }]);
+ legalPolicies(game, fixture.collector);
+ const accept = discoveryEntryMoveAction(view, sources), decline = discoveryEntryDeclineAction(view); assert.ok(accept && decline);
+ const before = structuredClone(homeworldClassicDiscoveryPlayer(game, fixture.collector)), custody = structuredClone(game.homeworlds!.custody);
+ const declined = applyAction(structuredClone(game), fixture.collector, decline);
+ assert.deepEqual(homeworldClassicDiscoveryPlayer(declined, fixture.collector).forces, before.forces);
+ for (const source of ['homeworld:emperor', 'homeworld:emperor:salusa', `${fixture.face}:0`, 'arrakeen:10']) {
+ const groups = [{ source, normal: 1, elite: 0 }];
+ assert.equal(discoveryEntryMoveAction(view, groups), null);
+ reject(game, fixture.collector, { ...accept, groups });
+ }
+ reject(game, fixture.fremen, accept);
+ reject(game, fixture.collector, { ...accept, groups: [{ ...sources[0], normal: 21 }] });
+ const entered = applyAction(game, fixture.collector, accept), after = homeworldClassicDiscoveryPlayer(entered, fixture.collector);
+ assert.equal(after.forces[`${fixture.face}:0`], fixture.amount);
+ assert.equal(after.elites?.forces[`${fixture.face}:0`] ?? 0, fixture.elite);
+ assert.equal(after.forces[`${fixture.parent}:${fixture.parentSector}`] ?? 0, 0);
+ assert.deepEqual(entered.homeworlds!.custody, custody);
+ assert.equal(after.reserves, before.reserves); assert.equal(after.elites?.reserves, before.elites?.reserves);
+ assert.equal(after.spice, before.spice); assert.equal(after.shipped, before.shipped); assert.equal(after.moved, before.moved);
+ assert.ok(gameTerritories(entered).every(territory => !territory.id.startsWith('homeworld:')));
+ assert.ok(entered.discoveries!.tokens.every(token => !token.territory?.startsWith('homeworld:')));
+ homeworldClassicDiscoveryInventory(entered);
+ }
+});
+
+void test('normal revealed nested shipment uses the original tariff and explicit Emperor Kaitain/Salusa custody; concealed sites and invalid source allocations fail', () => {
+ for (const advanced of [false, true]) {
+ const fixture = createHomeworldClassicDiscoveryFixture({ advanced, prepareImperialSplit: true });
+ const concealed = homeworldClassicDiscoveryShipmentWindow(fixture.game, fixture.collector);
+ const hiddenShip: Action = { type: 'ship', territory: fixture.face, sector: 0, amount: 2, elite: 0,
+ allyPayment: 0, homeworldSources: nativeShipmentSources(viewGame(concealed, fixture.collector), 2, 0) };
+ assert.equal(validGameLocation(concealed, fixture.face, 0), false);
+ reject(concealed, fixture.collector, hiddenShip);
+ let game = homeworldClassicDiscoveryShipmentWindow(enterHomeworldClassicDiscovery(fixture), fixture.collector);
+ const view = viewGame(game, fixture.collector), before = structuredClone(homeworldClassicDiscoveryPlayer(game, fixture.collector));
+ const homes = structuredClone(view.homeworlds!.worlds!), salusa = game.homeworlds!.custody!.salusa && { ...game.homeworlds!.custody!.salusa };
+ const sources = advanced ? { 'homeworld:emperor': { normal: 1, elite: 0 }, 'homeworld:emperor:salusa': { normal: 1, elite: 0 } }
+ : { 'homeworld:emperor': { normal: 2, elite: 0 } };
+ const action: Action = { type: 'ship', territory: fixture.face, sector: 0, amount: 2, elite: 0, allyPayment: 0, homeworldSources: sources };
+ legalPolicies(game, fixture.collector);
+ reject(game, fixture.collector, { ...action, territory: 'shrine' });
+ reject(game, fixture.collector, { ...action, sector: 1 });
+ reject(game, fixture.collector, { ...action, homeworldSources: { 'homeworld:fremen': { normal: 2, elite: 0 } } });
+ reject(game, fixture.collector, { ...action, homeworldSources: { 'homeworld:emperor': { normal: 1, elite: 0 } } });
+ reject(game, fixture.collector, { ...action, homeworldSources: { [`${fixture.face}:0`]: { normal: 2, elite: 0 } } });
+ game = settleHomeworldClassicDiscoveryArrival(applyAction(game, fixture.collector, action));
+ const after = homeworldClassicDiscoveryPlayer(game, fixture.collector);
+ assert.equal(after.spice, before.spice - 2, 'Printed nested tariff is one spice per shipped force.');
+ assert.equal(after.reserves, before.reserves - 2); assert.equal(after.elites?.reserves, before.elites?.reserves);
+ assert.equal(after.forces[`${fixture.face}:0`], before.forces[`${fixture.face}:0`] + 2);
+ assert.equal(after.elites?.forces[`${fixture.face}:0`], before.elites?.forces[`${fixture.face}:0`]);
+ assert.equal(after.shipped, true); assert.equal(after.moved, before.moved);
+ const nextHomes = viewGame(game, fixture.collector).homeworlds!.worlds!;
+ for (const home of homes.filter(home => home.native === fixture.collector)) {
+ const next = nextHomes.find(candidate => candidate.id === home.id)!;
+ assert.equal(next.forces[fixture.collector].normal, home.forces[fixture.collector].normal - (sources[home.id as keyof typeof sources]?.normal ?? 0));
+ assert.equal(next.forces[fixture.collector].elite, home.forces[fixture.collector].elite);
+ }
+ if (advanced) assert.equal(game.homeworlds!.custody!.salusa!.normal, salusa!.normal - 1);
+ homeworldClassicDiscoveryInventory(game);
+ }
+});
+
+void test('original native-home invasion remains a separate normal battle with native strength, native-only traitor calls and no Jacurutu reward', () => {
+ for (const advanced of [false, true]) {
+ const fixture = createHomeworldClassicDiscoveryFixture({ advanced, face: 'jacurutu-sietch' });
+ let game = homeworldClassicDiscoveryShipmentWindow(enterHomeworldClassicDiscovery(fixture), fixture.collector);
+ const home = viewGame(game, fixture.collector).homeworlds!.worlds!.find(world => world.native === fixture.fremen)!;
+ const choice = homeworldShipmentChoice(viewGame(game, fixture.collector), home.id,
+ { 'homeworld:emperor': { normal: 1, elite: 0 } });
+ assert.ok(choice.action); assert.equal(choice.cost, 1);
+ game = applyAction(game, fixture.collector, choice.action);
+ game = advanceHomeworldClassicDiscovery(game, state => state.phase === 6 && !!state.active && homeworldClassicDiscoveryClean(state));
+ const actor = game.active!; assert.ok(actor === fixture.collector || actor === fixture.fremen);
+ game = applyAction(game, actor, { type: 'chooseBattle', territory: home.id,
+ target: actor === fixture.collector ? fixture.fremen : fixture.collector });
+ game = advanceHomeworldClassicDiscovery(game, state => !!state.battle && homeworldClassicDiscoveryClean(state) && !state.battle.preparation);
+ const view = viewGame(game, fixture.collector), nativeBefore = structuredClone(homeworldClassicDiscoveryPlayer(game, fixture.fremen));
+ assert.equal(view.battle!.native, fixture.fremen); assert.equal(view.battle!.nativeBattleStrength, home.nativeBattleStrength);
+ assert.deepEqual(view.battle!.traitorVoters, [fixture.fremen]);
+ const discoveryBefore = structuredClone(game.discoveries), boardBefore = game.players.map(player => ({ id: player.id, forces: { ...player.forces } }));
+ const collection = quoteSpiceCollection(game).receipts.find(receipt => receipt.player === fixture.fremen)!.balance - nativeBefore.spice;
+ const invader = homeworldClassicDiscoveryPlayer(game, fixture.collector), native = homeworldClassicDiscoveryPlayer(game, fixture.fremen);
+ const weak = [...invader.leaders].filter(leader => !leader.dead).sort((a, b) => a.strength - b.strength)[0];
+ const strong = [...native.leaders].filter(leader => !leader.dead).sort((a, b) => b.strength - a.strength)[0];
+ legalPolicies(game, fixture.collector);
+ game = applyAction(game, fixture.collector, { type: 'battlePlan', dial: 0, support: 0, leader: weak.id });
+ legalPolicies(game, fixture.fremen);
+ game = applyAction(game, fixture.fremen, { type: 'battlePlan', dial: 0, support: 0, leader: strong.id });
+ reject(game, fixture.collector, { type: 'traitorCall', call: true });
+ game = applyAction(game, fixture.fremen, { type: 'traitorCall', call: false });
+ game = advanceHomeworldClassicDiscovery(game, state => !state.battle && !state.homeworldBattleLoss && homeworldClassicDiscoveryClean(state));
+ assert.equal(game.lastBattleContext!.territory, home.id); assert.equal(game.lastBattleContext!.winner, fixture.fremen);
+ assert.equal(homeworldClassicDiscoveryPlayer(game, fixture.collector).tanks, invader.tanks + 1);
+ const nativeAfter = homeworldClassicDiscoveryPlayer(game, fixture.fremen);
+ assert.equal(nativeAfter.reserves, nativeBefore.reserves); assert.equal(nativeAfter.tanks, nativeBefore.tanks);
+ assert.equal(nativeAfter.spice, nativeBefore.spice + (game.phase === 7 ? collection : 0), 'One undialed invading loss earns no Jacurutu reward on a Homeworld.');
+ assert.deepEqual(game.discoveries, discoveryBefore);
+ assert.deepEqual(game.players.map(player => ({ id: player.id, forces: player.forces })), boardBefore);
+ homeworldClassicDiscoveryInventory(game);
+ }
+});
diff --git a/tests/homeworld-classic-skills-runtime.test.ts b/tests/homeworld-classic-skills-runtime.test.ts
new file mode 100644
index 00000000..8c16ca40
--- /dev/null
+++ b/tests/homeworld-classic-skills-runtime.test.ts
@@ -0,0 +1,206 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { applyAction, type Action, type Game } from '../game/engine';
+import { DIFFICULTIES } from '../game/bot-profiles';
+import type { FactionId } from '../game/catalog';
+import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
+import { homeworldContext } from '../game/homeworld-game';
+import { homeworldForceGroups } from '../game/homeworld-custody';
+import type { HomeworldCombatLossContext } from '../game/homeworld-combat-loss';
+import { quoteSukPhysicalRescue, type SukReserveDestinations } from '../game/suk-graduate';
+import {
+ advanceClassicHomeworldSkills as advance, assertClassicHomeworldSkillsCustody as custody,
+ classicHomeworldSkillsBotOffers as offers, classicHomeworldSkillsPlayer as player,
+ createClassicHomeworldSkillsFixture as fixture, createClassicHomeworldSkillsSetup as setup,
+ quoteClassicHomeworldSkillsBattle as quoteBattle, revealClassicHomeworldSkillsBattle as reveal,
+ settleClassicHomeworldSkillsBattle as settle,
+} from './fixture-homeworld-classic-skills';
+
+function legalOffers(game: Game, actor: string): void {
+ for (const level of DIFFICULTIES) {
+ const actions = offers(game, actor, level); assert.ok(actions.length, `${level} has a real supported offer`);
+ for (const action of actions) custody(applyAction(game, actor, action));
+ }
+}
+function context(game: Game): HomeworldCombatLossContext {
+ return { advanced: game.advanced, players: game.players.map(p => {
+ const total = Object.values(p.forces).reduce((a, b) => a + b, 0);
+ const elite = Object.values(p.elites?.forces ?? {}).reduce((a, b) => a + b, 0);
+ return { id: p.id, faction: p.faction, reserves: p.reserves, eliteReserves: p.elites?.reserves ?? 0,
+ tanks: p.tanks, eliteTanks: p.elites?.tanks ?? 0, battleLosses: p.battleLosses,
+ boardForces: { normal: total - elite, elite } };
+ }) };
+}
+function pool(game: Game, actor: string, location: string) {
+ return homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!)
+ .find(home => home.id === location)!.forces[actor] ?? { normal: 0, elite: 0 };
+}
+function reject(game: Game, actor: string, action: Action): void {
+ const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
+}
+function rescueChoice(game: Game, elite: boolean): number {
+ const decision = game.decision; assert.equal(decision?.kind, 'sukRescue');
+ if (decision?.kind !== 'sukRescue') throw Error('A real rescue choice is required');
+ const maximum = Math.max(...decision.options.map(option => option.normal + option.elite));
+ const choice = decision.options.findIndex(option => option.normal + option.elite === maximum &&
+ (elite ? option.elite > 0 && option.kept?.kind === 'elite' : option.kept?.kind === 'normal'));
+ assert.ok(choice >= 0); return choice;
+}
+function splitReturn(game: Game, choice: number): SukReserveDestinations | undefined {
+ const decision = game.decision; assert.ok(decision?.kind === 'sukRescue');
+ if (!decision.reserveHomes) return undefined;
+ const option = decision.options[choice];
+ const normal = option.normal - (option.kept?.kind === 'normal' ? 1 : 0);
+ const elite = option.elite - (option.kept?.kind === 'elite' ? 1 : 0);
+ if (!normal && !elite) return undefined;
+ // Deliberately NOT the revival mapping: any returned ordinary pieces go to
+ // Salusa and any returned Sardaukar go to Kaitain. This application placement
+ // choice exercises exact two-home conservation, not a publisher ruling.
+ return Object.fromEntries(decision.reserveHomes.map(home => [home.id,
+ home.secondary ? { normal, elite: 0 } : { normal: 0, elite }]));
+}
+
+void test('original Basic/Advanced two-through-six classic Homeworld setups retain starting hands and all fourteen physical skills', () => {
+ const roster: FactionId[] = ['emperor', 'guild', 'atreides', 'harkonnen', 'fremen', 'beneGesserit'];
+ for (const advanced of [false, true]) for (let seats = 2; seats <= 6; seats++) {
+ const f = setup({ advanced, roster: roster.slice(0, seats) });
+ const physical = f.offered.leaderSkills!;
+ assert.deepEqual([...physical.deck, ...Object.values(physical.offers).flatMap(o => o.cards), ...physical.assignments.map(a => a.skill)].sort(), LEADER_SKILL_CARDS.map(c => c.id).sort());
+ for (const p of f.afterSetup.players) {
+ assert.equal(p.hand.length, p.faction === 'harkonnen' ? 2 : 1);
+ }
+ for (const p of f.offered.players) legalOffers(f.offered, p.id);
+ custody(f.offered); custody(f.afterSetup);
+ }
+});
+
+void test('every physical skill remains selectable through actual Emperor offers', () => {
+ for (const advanced of [false, true]) for (const card of LEADER_SKILL_CARDS) {
+ const f = setup({ advanced, roster: ['emperor', 'guild'], skill: card.id });
+ assert.equal(f.afterSetup.leaderSkills!.assignments.find(a => a.owner === 'emperor')!.skill, card.id);
+ legalOffers(f.offered, 'emperor');
+ custody(f.afterSetup);
+ }
+});
+
+
+for (const advanced of [false, true]) {
+ void test(`${advanced ? 'Advanced' : 'Basic'} trained native and invader add the actual Mentat bonus without turning native strength into counters`, () => {
+ for (const kind of ['native-bonus', 'visitor-bonus'] as const) {
+ const f = fixture({ kind, advanced });
+ for (const actor of [f.owner, f.opponent]) legalOffers(f.game, actor);
+ const revealed = reveal(f), quote = quoteBattle(revealed);
+ const own = revealed.battle!.attacker === f.owner ? 'attacker' : 'defender';
+ const leader = player(revealed, f.owner).leaders.find(l => l.id === f.trainer)!;
+ const alternative = player(f.game, f.owner).leaders.find(disc => disc.id !== f.trainer && !disc.dead)!;
+ const untrained = reveal({ ...f, plans: f.plans.map(step => step.actor === f.owner
+ ? { ...step, action: { ...step.action, leader: alternative.id } } : step) });
+ const untrainedQuote = quoteBattle(untrained);
+ assert.equal(quote.scores![own] - untrainedQuote.scores![own], leader.strength - alternative.strength + 2);
+ const beforePool = pool(revealed, f.owner, f.territory);
+ const completed = settle(revealed);
+ assert.equal(completed.lastBattleContext!.winner, f.owner);
+ const losses = player(completed, f.owner).tanks - player(revealed, f.owner).tanks;
+ const afterPool = pool(completed, f.owner, f.territory);
+ assert.equal(beforePool.normal + beforePool.elite - afterPool.normal - afterPool.elite, losses);
+ assert.equal(player(completed, f.owner).leaders.find(l => l.id === f.trainer)!.dead, false);
+ custody(completed);
+ }
+ });
+
+ void test(`${advanced ? 'Advanced' : 'Basic'} actual lethal native and invader plans kill the trained disc and return its one physical skill exactly once`, () => {
+ for (const kind of ['native-death', 'visitor-death'] as const) {
+ const f = fixture({ kind, advanced }), revealed = reveal(f);
+ assert.equal(quoteBattle(revealed).leaderDeaths[revealed.battle!.attacker === f.owner ? 'attacker' : 'defender'], true);
+ const done = settle(revealed);
+ assert.equal(player(done, f.owner).leaders.find(l => l.id === f.trainer)!.dead, true);
+ assert.ok(!done.leaderSkills!.assignments.some(a => a.leader === f.trainer));
+ assert.equal(done.leaderSkills!.deck.filter(id => id === 'mentat').length, 1);
+ const ended = advance(done, game => game.turn > done.turn);
+ assert.equal(ended.leaderSkills!.deck.filter(id => id === 'mentat').length, 1);
+ custody(done); custody(ended);
+ }
+ });
+
+ void test(`${advanced ? 'Advanced' : 'Basic'} native Homeworld trained Suk saves actual counters in their same reserve pool`, () => {
+ const f = fixture({ kind: 'native-suk', advanced }), revealed = reveal(f), pending = settle(revealed, 'suk');
+ assert.equal(pending.pendingSukRescue!.territory, f.territory);
+ assert.equal(pending.pendingSukRescue!.skill.mode, 'skilled');
+ assert.equal(pending.pendingSukRescue!.pool.length, 1);
+ const choice = rescueChoice(pending, advanced);
+ assert.ok(pending.decision?.kind === 'sukRescue');
+ assert.equal(pending.decision.reserveHomes, undefined, 'Native rescue cannot offer a free Emperor internal move');
+ legalOffers(pending, f.owner);
+ const before = pool(pending, f.owner, f.territory), option = pending.decision.options[choice];
+ reject(pending, f.owner, { type: 'decision', event: pending.decision.event, choice,
+ destinations: { 'homeworld:emperor': { normal: advanced ? 0 : 1, elite: advanced ? 1 : 0 } } });
+ const done = applyAction(JSON.parse(JSON.stringify(pending)), f.owner, { type: 'decision', event: pending.decision.event, choice });
+ const completed = settle(done), after = pool(completed, f.owner, f.territory);
+ assert.equal(before.normal - after.normal, pending.pendingSukRescue!.losses!.normal - option.normal);
+ assert.equal(before.elite - after.elite, pending.pendingSukRescue!.losses!.elite - option.elite);
+ if (advanced) assert.deepEqual(completed.homeworlds!.custody!.salusa, pending.homeworlds!.custody!.salusa,
+ 'Three dialed native Sardaukar saved at Salusa never leave Salusa or duplicate their reserve totals');
+ custody(completed);
+ });
+
+ void test(`${advanced ? 'Advanced' : 'Basic'} visitor Homeworld and Arrakis trained Suk conserve typed casualties, board/visitor pools and native return allocations`, () => {
+ for (const kind of ['visitor-suk', 'arrakis-suk'] as const) {
+ const f = fixture({ kind, advanced }), revealed = reveal(f);
+ let pending = settle(revealed, 'suk');
+ assert.ok(pending.decision?.kind === 'sukRescue');
+ const choice = rescueChoice(pending, false), option = pending.decision.options[choice];
+ const destinations = splitReturn(pending, choice), original = pending.pendingSukRescue!;
+ assert.equal(original.skill.mode, 'skilled');
+ if (advanced) assert.equal(pending.decision.reserveHomes?.length, 2);
+ if (advanced) {
+ assert.equal(original.losses!.normal, 2);
+ assert.equal(original.losses!.elite, 1);
+ }
+ legalOffers(pending, f.owner);
+ const physical = quoteSukPhysicalRescue(context(pending), pending.homeworlds!.custody!, {
+ player: f.owner, territory: f.territory, skill: original.skill, pool: original.pool,
+ losses: original.losses!, option, destinations,
+ });
+ assert.equal(physical.receipt.source, kind === 'visitor-suk' ? 'visitor-homeworld' : 'arrakis');
+ if (advanced) {
+ const incomplete = { 'homeworld:emperor': { normal: 0, elite: 0 } };
+ reject(pending, f.owner, { type: 'decision', event: pending.decision.event, choice, destinations: incomplete });
+ reject(pending, f.owner, { type: 'decision', event: pending.decision.event, choice, destinations: { 'homeworld:guild': { normal: 2, elite: 0 } } });
+ reject(pending, f.owner, { type: 'decision', event: pending.decision.event, choice });
+ }
+ const before = player(pending, f.owner);
+ pending = applyAction(JSON.parse(JSON.stringify(pending)), f.owner, { type: 'decision', event: pending.decision.event, choice, ...(destinations ? { destinations } : {}) });
+ const done = settle(pending), after = player(done, f.owner);
+ assert.equal(after.tanks - before.tanks, original.losses!.normal + original.losses!.elite - option.normal - option.elite);
+ assert.deepEqual(done.homeworlds!.custody, physical.custody);
+ assert.equal(after.reserves, physical.players.find(p => p.id === f.owner)!.reserves);
+ assert.equal(after.elites?.reserves, physical.players.find(p => p.id === f.owner)!.eliteReserves);
+ assert.equal(done.pendingSukRescue ?? null, null);
+ custody(done);
+ }
+ });
+
+ void test(`${advanced ? 'Advanced' : 'Basic'} real empty-territory Smuggler shipment withdraws six physical counters priced as five and presents the actual Bureaucrat payer invoice`, () => {
+ const f = fixture({ kind: 'smuggler-payment', advanced });
+ const before = f.beforeShipment!, after = f.afterShipment!;
+ assert.equal(player(after, f.owner).reserves, player(before, f.owner).reserves - 6);
+ assert.equal(player(after, f.owner).spice, player(before, f.owner).spice - 5);
+ assert.equal(Object.values(player(after, f.owner).forces).reduce((a, b) => a + b, 0), 6);
+ if (advanced) assert.equal(player(after, f.owner).elites!.reserves, player(before, f.owner).elites!.reserves - 2);
+ legalOffers(before, f.owner);
+ const invoice = advance(after, game => game.decision?.kind === 'bureaucratPayment');
+ const payment = invoice.bureaucratPayments!.pending!.source;
+ assert.equal(payment.kind, 'shipment'); assert.equal(payment.payer, f.owner);
+ assert.equal(payment.payee, f.opponent); assert.equal(payment.amount, 5);
+ const bureaucrat = invoice.decision!.player;
+ assert.notEqual(bureaucrat, payment.payer); assert.notEqual(bureaucrat, payment.payee);
+ legalOffers(invoice, bureaucrat);
+ const paid = applyAction(JSON.parse(JSON.stringify(invoice)), bureaucrat, { type: 'decision', event: invoice.decision!.kind === 'bureaucratPayment' ? invoice.decision!.event : '', redirect: true });
+ const done = advance(paid, game => !game.response && !game.decision && !game.phaseOpening);
+ assert.equal(player(done, f.opponent).spice, player(before, f.opponent).spice + 3);
+ assert.equal(player(done, f.owner).spice, player(before, f.owner).spice - 5);
+ assert.equal(done.bureaucratPayments!.used.length, 1);
+ assert.deepEqual(player(done, f.owner).forces, player(after, f.owner).forces, 'Invoice settlement cannot replay physical arrival');
+ custody(done);
+ });
+}
diff --git a/tools/faction-games.ts b/tools/faction-games.ts
index 4889d426..b016e895 100644
--- a/tools/faction-games.ts
+++ b/tools/faction-games.ts
@@ -35,7 +35,7 @@ import {
type Game,
} from '../game/engine';
import { sampleInventory, verifySampleCustody } from './sample-custody';
-import { classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile } from '../game/leader-skill-profile';
+import { classicHomeworldLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile } from '../game/leader-skill-profile';
import { createTechTokens } from '../game/tech-tokens';
import { createStrongholdCards } from '../game/stronghold-cards';
import { privateOutputDirectory, sourceSnapshot } from './verification';
@@ -56,7 +56,8 @@ type Profile = 'base' | 'choam' | 'ecaz' | 'ecaz-treachery' | 'ecaz-occupy' | 'm
| 'discovery-nexus-skills' | 'discovery-nexus-skills-tech' | 'discovery-nexus-skills-stronghold' | 'discovery-nexus-skills-stronghold-tech'
| 'paired-discovery-nexus' | 'paired-discovery-nexus-tech' | 'paired-discovery-nexus-stronghold' | 'paired-discovery-nexus-stronghold-tech'
| 'paired-discovery-nexus-skills' | 'paired-discovery-nexus-skills-tech' | 'paired-discovery-nexus-skills-stronghold' | 'paired-discovery-nexus-skills-stronghold-tech'
- | 'e3-discovery-nexus-skills' | 'e3-discovery-nexus-skills-tech' | 'e3-discovery-nexus-skills-stronghold' | 'e3-discovery-nexus-skills-stronghold-tech';
+ | 'e3-discovery-nexus-skills' | 'e3-discovery-nexus-skills-tech' | 'e3-discovery-nexus-skills-stronghold' | 'e3-discovery-nexus-skills-stronghold-tech'
+ | 'homeworld-skills' | 'homeworld-discovery';
const discoveryStrongholdProfile = (profile: string) => profile === 'discovery-stronghold' ||
profile === 'discovery-stronghold-tech' || profile === 'discovery-skills-stronghold' ||
profile === 'discovery-skills-stronghold-tech' || profile === 'native-discovery-skills-stronghold' ||
@@ -79,6 +80,7 @@ const nativeDiscoveryNexusSkillsProfile = (profile: string) =>
profile === 'paired-discovery-nexus-skills-stronghold' || profile === 'paired-discovery-nexus-skills-stronghold-tech' ||
profile === 'e3-discovery-nexus-skills' || profile === 'e3-discovery-nexus-skills-tech' ||
profile === 'e3-discovery-nexus-skills-stronghold' || profile === 'e3-discovery-nexus-skills-stronghold-tech';
+const homeworldClassicModuleProfile = (profile: string) => profile === 'homeworld-skills' || profile === 'homeworld-discovery';
type Rules = 'basic' | 'advanced';
type Scenario = {
@@ -153,6 +155,9 @@ const BASE_SCENARIOS: readonly Scenario[] = [2, 3, 4, 5, 6].flatMap((players) =>
roster: BASE_ROSTER.slice(0, players),
})),
);
+const HOMEWORLD_CLASSIC_MODULE_SCENARIOS: readonly Scenario[] =
+ (['homeworld-skills', 'homeworld-discovery'] as const).flatMap((profile, band) =>
+ BASE_SCENARIOS.map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 6400 + band * 100 + index })));
const NEXUS_SKILLS_SCENARIOS: readonly Scenario[] = BASE_SCENARIOS.map((scenario, index) => ({
...scenario, profile: 'nexus-skills', ordinal: 1410 + index,
}));
@@ -560,9 +565,9 @@ const SKILLS_STRONGHOLD_SCENARIOS: readonly Scenario[] =
const strongholdSkillProfile = (profile: string) => profile === 'skills-stronghold' || profile === 'skills-stronghold-tech';
const discoverySkillsProfile = (profile: string) => profile === 'discovery-skills' || profile === 'discovery-skills-tech' ||
profile === 'discovery-skills-stronghold' || profile === 'discovery-skills-stronghold-tech';
-const skillProfile = (profile: string) => nativeDiscoveryNexusSkillsProfile(profile) || discoveryNexusSkillsProfile(profile) || nativeDiscoverySkillsProfile(profile) || discoverySkillsProfile(profile) || e3NexusSkillsProfile(profile) || pairedNexusSkillsModuleProfile(profile) || nexusSkillsModuleProfile(profile) || profile === 'nexus-skills' || profile === 'paired-nexus-skills' || profile === 'moritani-skills' || profile === 'tleilaxu-skills' || profile === 'ix-skills' || profile === 'choam-skills' || profile === 'richese-skills' || profile === 'ecaz-skills' || profile === 'skills-tech' || strongholdSkillProfile(profile);
+const skillProfile = (profile: string) => profile === 'homeworld-skills' || nativeDiscoveryNexusSkillsProfile(profile) || discoveryNexusSkillsProfile(profile) || nativeDiscoverySkillsProfile(profile) || discoverySkillsProfile(profile) || e3NexusSkillsProfile(profile) || pairedNexusSkillsModuleProfile(profile) || nexusSkillsModuleProfile(profile) || profile === 'nexus-skills' || profile === 'paired-nexus-skills' || profile === 'moritani-skills' || profile === 'tleilaxu-skills' || profile === 'ix-skills' || profile === 'choam-skills' || profile === 'richese-skills' || profile === 'ecaz-skills' || profile === 'skills-tech' || strongholdSkillProfile(profile);
const discoveryProfile = (profile: string) => profile === 'discovery' || profile === 'discovery-tech' ||
- profile === 'discovery-stronghold' || profile === 'discovery-stronghold-tech';
+ profile === 'discovery-stronghold' || profile === 'discovery-stronghold-tech' || profile === 'homeworld-discovery';
const assassinationScenario = (scenario: Scenario) => scenario.rules === 'advanced' &&
scenario.roster.includes('moritani') &&
(scenario.profile === 'moritani-assassinate' || discoveryProfile(scenario.profile) || nativeStrongholdProfile(scenario.profile) || nativeTechProfile(scenario.profile) || skillProfile(scenario.profile));
@@ -630,6 +635,7 @@ function usage() {
+ '\nDiscovery uses fresh classic, paired E1/E2 or standalone Ecaz OR Moritani setups with original selected decks, six Discovery territory cards, Great Maker and eight physical tokens in Basic/Advanced2..6. Discovery-tech preserves unused original Tech3..6. Native setup, actual token entry, typed force paths, stash and nested location controls reuse original handlers. No Skills/HW/Nexus/Strongholds, mixed E3 families, new contested/shared-payer rulings, public starts or played-game conversion.'
+ '\nDiscovery-skills selects classic/base33/all14 Skills with original Discovery7/8, Basic/Advanced2..6. Discovery-skills-tech preserves unused original Tech3..6. Original trained posture, physical free entry, fixed-three carried movement, paid nested routes, stash capacity, skill battle rescue/rewards and distinct-party Bureaucrat invoices remain. Native families, Nexus/Strongholds/HW, Banker/Mentat previews, pending contested/shared/mixed effects, public starts and save conversion stay separate.'
+ '\nDiscovery-nexus uses classic/base33/all12 Nexus/Discovery7+8, Basic/Advanced2..6. Discovery-nexus-tech adds original Tech3+; discovery-nexus-stronghold adds Advanced Strongholds2+; both requires3+ Advanced. Original Great Maker/worm/vote/ride, settled-alliance end-Spice closing deal, end-Mentat held-card settlement, next-turn entry and source-clear borrowed force/tariff/card effects remain. No Skills/HW/native families, mixed-module Fremen Betrayal, new pending rulings, public starts or save conversion.'
+ + '\nHomeworld-skills and homeworld-discovery use original classic/base33 rosters in Basic/Advanced2..6. The first retains all14 original offered training, actual Homeworld combat/rescue and split native custody; the second retains Discovery7/8, actual Maker vote/typed reserve ride, next-turn parent-board entry and split Imperial nested shipment. Original setup/cards/force sources remain. No other optional modules, native families, public starts, save conversion or pending-rule changes.'
);
}
@@ -660,6 +666,8 @@ function positive(value: string | undefined) {
}
function scenarioName(scenario: Scenario) {
+ if (homeworldClassicModuleProfile(scenario.profile))
+ return `${scenario.profile}-classic-${scenario.roster.join('-')}-${scenario.roster.length}-${scenario.rules}`;
if (nativeDiscoverySkillsProfile(scenario.profile) || discoveryNexusSkillsProfile(scenario.profile) ||
pairedDiscoveryNexusSampleProfile(scenario.profile) || nativeDiscoveryNexusSkillsProfile(scenario.profile))
return `${scenario.profile}-${scenario.expansions.join('-')}-${scenario.roster.join('-')}-${scenario.roster.length}-${scenario.rules}`;
@@ -754,7 +762,7 @@ function freshGame(scenario: Scenario) {
if (scenario.ecazTreachery) game.ecazTreachery = true;
if (discoveryTechProfile(scenario.profile) || scenario.profile === 'skills-tech' || scenario.profile === 'skills-stronghold-tech' || nativeTechProfile(scenario.profile) || nexusTechProfile(scenario.profile)) game.techTokens = createTechTokens();
if (discoveryStrongholdProfile(scenario.profile) || strongholdSkillProfile(scenario.profile) || nexusStrongholdProfile(scenario.profile)) game.strongholdCards = createStrongholdCards();
- if (scenario.profile === 'homeworld' || scenario.profile === 'homeworld-occupation' || scenario.profile === 'homeworld-nexus' || scenario.profile === 'combined-homeworld-nexus')
+ if (homeworldClassicModuleProfile(scenario.profile) || scenario.profile === 'homeworld' || scenario.profile === 'homeworld-occupation' || scenario.profile === 'homeworld-nexus' || scenario.profile === 'combined-homeworld-nexus')
game.homeworlds = { custody: null };
if (scenario.profile === 'paired-nexus-skills' || scenario.profile === 'nexus-skills' || scenario.profile === 'nexus' || nexusModuleProfile(scenario.profile) || scenario.profile === 'homeworld-nexus' || scenario.profile === 'choam-nexus' || scenario.profile === 'ecaz-nexus' || scenario.profile === 'ix-nexus' || scenario.profile === 'combined-nexus' || scenario.profile === 'combined-homeworld-nexus')
game.nexusCards = { cards: null, phase: null };
@@ -819,7 +827,7 @@ function resumedGame(path: string) {
throw new Error(
'--resume is not an incomplete faction-games snapshot with saved AI profiles.',
);
- const composedSkills = (classicDiscoveryLeaderSkillsProfile(game) || standaloneE3NexusLeaderSkillsProfile(game) || classicNexusLeaderSkillsProfile(game) || classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game) || strongholdLeaderSkillsProfile(game)) && !game.spiceBankerIncomePreview;
+ const composedSkills = (classicHomeworldLeaderSkillsProfile(game) || classicDiscoveryLeaderSkillsProfile(game) || standaloneE3NexusLeaderSkillsProfile(game) || classicNexusLeaderSkillsProfile(game) || classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game) || strongholdLeaderSkillsProfile(game)) && !game.spiceBankerIncomePreview;
const discovery = game.discoveryEnabled === true && !!game.discoveries;
if (
(game.leaderSkills && !nativeExpansionLeaderSkillsProfile(game) && !composedSkills) ||
@@ -828,10 +836,10 @@ function resumedGame(path: string) {
game.mentatQuestionPreview
)
throw new Error('--resume sample scenarios exclude unsupported optional modules.');
- const scenario = [...SCENARIOS, ...COMBINED_NEXUS_SCENARIOS, ...COMBINED_HOMEWORLD_NEXUS_SCENARIOS, ...BASE_SCENARIOS, ...MODULE_SCENARIOS, ...EXPANSION_ROSTER_SCENARIOS, ...ECAZ_TREACHERY_SCENARIOS, ...ECAZ_OCCUPY_SCENARIOS, ...PAIRED_NEXUS_SCENARIOS, ...MORITANI_ASSASSINATE_SCENARIOS, ...STRONGHOLD_FACTIONS_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...IX_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS, ...RICHESE_SKILLS_SCENARIOS, ...ECAZ_SKILLS_SCENARIOS, ...SKILLS_TECH_SCENARIOS, ...SKILLS_STRONGHOLD_SCENARIOS, ...NATIVE_TECH_SCENARIOS, ...CLASSIC_NEXUS_MODULE_SCENARIOS, ...PAIRED_NEXUS_MODULE_SCENARIOS, ...NEXUS_SKILLS_SCENARIOS, ...PAIRED_NEXUS_SKILLS_SCENARIOS, ...NEXUS_SKILLS_MODULE_SCENARIOS, ...PAIRED_NEXUS_SKILLS_MODULE_SCENARIOS, ...E3_NEXUS_SKILLS_SCENARIOS, ...HOMEWORLD_OCCUPATION_SCENARIOS, ...DISCOVERY_SCENARIOS, ...DISCOVERY_SKILLS_SCENARIOS, ...DISCOVERY_NEXUS_SCENARIOS, ...DISCOVERY_STRONGHOLD_SCENARIOS, ...NATIVE_DISCOVERY_SKILLS_SCENARIOS, ...DISCOVERY_NEXUS_SKILLS_SCENARIOS, ...PAIRED_DISCOVERY_NEXUS_SCENARIOS, ...NATIVE_DISCOVERY_NEXUS_SKILLS_SCENARIOS].find(
+ const scenario = [...SCENARIOS, ...COMBINED_NEXUS_SCENARIOS, ...COMBINED_HOMEWORLD_NEXUS_SCENARIOS, ...BASE_SCENARIOS, ...MODULE_SCENARIOS, ...EXPANSION_ROSTER_SCENARIOS, ...ECAZ_TREACHERY_SCENARIOS, ...ECAZ_OCCUPY_SCENARIOS, ...PAIRED_NEXUS_SCENARIOS, ...MORITANI_ASSASSINATE_SCENARIOS, ...STRONGHOLD_FACTIONS_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...IX_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS, ...RICHESE_SKILLS_SCENARIOS, ...ECAZ_SKILLS_SCENARIOS, ...SKILLS_TECH_SCENARIOS, ...SKILLS_STRONGHOLD_SCENARIOS, ...NATIVE_TECH_SCENARIOS, ...CLASSIC_NEXUS_MODULE_SCENARIOS, ...PAIRED_NEXUS_MODULE_SCENARIOS, ...NEXUS_SKILLS_SCENARIOS, ...PAIRED_NEXUS_SKILLS_SCENARIOS, ...NEXUS_SKILLS_MODULE_SCENARIOS, ...PAIRED_NEXUS_SKILLS_MODULE_SCENARIOS, ...E3_NEXUS_SKILLS_SCENARIOS, ...HOMEWORLD_OCCUPATION_SCENARIOS, ...DISCOVERY_SCENARIOS, ...DISCOVERY_SKILLS_SCENARIOS, ...DISCOVERY_NEXUS_SCENARIOS, ...DISCOVERY_STRONGHOLD_SCENARIOS, ...NATIVE_DISCOVERY_SKILLS_SCENARIOS, ...DISCOVERY_NEXUS_SKILLS_SCENARIOS, ...PAIRED_DISCOVERY_NEXUS_SCENARIOS, ...NATIVE_DISCOVERY_NEXUS_SKILLS_SCENARIOS, ...HOMEWORLD_CLASSIC_MODULE_SCENARIOS].find(
(candidate) =>
(nativeDiscoverySkillsProfile(candidate.profile) || discoveryProfile(candidate.profile) || discoverySkillsProfile(candidate.profile) || discoveryNexusProfile(candidate.profile)) === discovery &&
- (candidate.profile === 'homeworld' || candidate.profile === 'homeworld-occupation' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.homeworlds &&
+ (homeworldClassicModuleProfile(candidate.profile) || candidate.profile === 'homeworld' || candidate.profile === 'homeworld-occupation' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.homeworlds &&
(candidate.profile === 'paired-nexus-skills' || candidate.profile === 'nexus-skills' || candidate.profile === 'nexus' || nexusModuleProfile(candidate.profile) || candidate.profile === 'homeworld-nexus' || candidate.profile === 'choam-nexus' || candidate.profile === 'ecaz-nexus' || candidate.profile === 'ix-nexus' || candidate.profile === 'combined-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.nexusCards &&
(candidate.profile === 'homeworld-occupation') === !!game.homeworldOccupationPreview &&
skillProfile(candidate.profile) === !!game.leaderSkills &&
@@ -1059,7 +1067,8 @@ async function main() {
if ((discoveryTechProfile(profile) || (discoveryNexusProfile(profile) && nexusTechProfile(profile))) && players === 2)
throw new Error('Discovery with Tech Tokens requires three through six players.');
const resume = values.resume ? resumedGame(values.resume) : null;
- const requestedSamples = nativeDiscoveryNexusSkillsProfile(profile) ? NATIVE_DISCOVERY_NEXUS_SKILLS_SCENARIOS
+ const requestedSamples = homeworldClassicModuleProfile(profile) ? HOMEWORLD_CLASSIC_MODULE_SCENARIOS
+ : nativeDiscoveryNexusSkillsProfile(profile) ? NATIVE_DISCOVERY_NEXUS_SKILLS_SCENARIOS
: discoveryNexusSkillsProfile(profile) ? DISCOVERY_NEXUS_SKILLS_SCENARIOS
: pairedDiscoveryNexusSampleProfile(profile) ? PAIRED_DISCOVERY_NEXUS_SCENARIOS
: nativeDiscoverySkillsProfile(profile) ? NATIVE_DISCOVERY_SKILLS_SCENARIOS
diff --git a/tools/start-prototype.ts b/tools/start-prototype.ts
index f5671136..4f6113ed 100644
--- a/tools/start-prototype.ts
+++ b/tools/start-prototype.ts
@@ -44,6 +44,7 @@ async function main() {
'\nDiscovery opts fresh classic, selected E1/E2 native families or standalone Ecaz OR Moritani with classics into original Discovery setup in Basic/Advanced2–6, optionally retaining unused original Tech3–6 and Advanced Strongholds2–6. Original decks/cache, HMS/Face Dancers, typed entry, revealed-only nested No-Fields, stash limits, end-Mentat held claims and native outcomes remain. Advanced Moritani excludes Harkonnen. Skills/HW/Nexus, paired/mixed E3, contested/shared-payer rulings, public starts and played-game conversion stay separate.',
'\nLeader-skills --discoveries explicitly composes fresh supported classic or native original family decks/all14 Skills with Discovery7/8 at2–6 seats Basic/Advanced under each existing roster predicate. Unused original Tech requires3–6; original Strongholds requires Advanced2–6. --nexus-cards additionally retains all12 with classic factions, both original paired E1/E2 natives, or standalone Ecaz OR Moritani with classics. Actual offered training/starting hands, Maker votes/typed rides, both Advanced piles and settled-alliance end-Spice closing deals remain. Signed native pairs, trained rescue/cleanup/Tech/Face Dance and mandatory owner-labelled Ecaz or original Moritani consumers keep their sources. Mixed/E3 pairs, HW/variants, Banker/Mentat previews, shared Duke/borrowed Smuggler/pair companion/native return and other pending rulings, public starts and save conversion remain guarded.',
'\nNexus --discoveries explicitly composes fresh classic/base33 OR both native Ixians+Tleilaxu/Ix47 OR CHOAM+Richese/CHOAM35+cache with all12 Nexus and original Discovery7/8 at2–6 seats Basic/Advanced. Unused Tech requires3+; optional Strongholds require Advanced; both3+. Actual Great Maker losses/votes/typed reserve rides, both Advanced piles, settled-alliance end-Spice closing deals and original held claims remain. Signed native two-marker nested shipments retain one printed invoice, current revealed destination and original reserve materialization. Native Skills, HW, E3/mixed families, mixed-module Fremen Betrayal, pending rulings, public activation and save conversion stay guarded.',
+ '\nA fresh classic/base33 lobby with Homeworlds already enabled now supports leader-skills or discovery alone, Basic/Advanced2–6. Original all14 offered training or Discovery7/8 and native/split Imperial pools remain. Actual Homeworld battles, source-qualified rescue, Maker votes/typed reserve rides, parent-only entry and paid revealed nested routes use original controls. This does not add other overlays/native families, public starts or convert played saves.',
);
return;
}