Compose classic Homeworld optional modules through original rules

This commit is contained in:
Quintine committed 2026-10-06 18:56:55 +11:00
1 parent 3dd36bd9ff
commit fa025220f8
18 files changed
+1220 -94

No files matched your search

+15 -9
View File
@@ -17,15 +17,21 @@ open mode gates.
## Current checkpoint and work
**In progress — classic Homeworld optional modules.** Next source-clear batch
composes Skills+Discovery together and optionally original Tech3+ or Advanced
Strongholds2+ across each classic Homeworld Skills/Discovery family. Original
four-seat fresh combined entry rejects before the change and now reaches
genuine skill/Traitor setup; full changed-path verification is still pending.
Parent owns shared admission, engine, tools and controls. Disjoint original
Skills/Discovery and industry/held-card programmes own their new fixture/runtime
pairs. No Nexus/native rosters, pending rulings, public starts or played-save
conversion are opened by this batch.
**Classic Homeworld optional modules — bounded Development Verified.**
Fresh classic Basic/Advanced2–6 now composes Skills, Discovery or both with
original Tech3+ and/or Advanced Strongholds2+. Original all14/DS7+8 setup,
native pools, phase-end industry, source-local held effects and mandatory
winner Tech timing remain. New14 cases and affected145/145 across17 files,
types/lint/build pass. Fourteen completed original six-seat samples total
9008/9008 actions/237 JSON across two source batches; an incidental internal
view-copy failure was preserved, repaired and continued, not hidden or
reported as an uninterrupted first-tree pass. Actual human390px
`XJCJ3H3W`v17 submits typed nested Suk allocation; `WM8E7CFG`v17 chooses
Spice Production after real Homeworld battle cleanup, retaining the actual
visitor/native armies. Both tabs closed and temporary scripts removed.
[Exact scope and provenance](HOMEWORLD_RUNTIME_IMPLEMENTATION.md#classic-optional-module-compositions--6-october-2026).
Nexus/native rosters, pending specific rulings, public starts and protected
deployment remain separate; the complete project goal is unchanged.
**Classic Homeworld + Skills or Discovery — bounded Development Verified.**
Fresh Basic/Advanced two–six unique base factions retain original Homeworld
+14 -12
View File
@@ -7,17 +7,19 @@ 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.
Fresh private classic Discovery setup retains Homeworlds in Basic/Advanced
two–six unique base factions, original seven cards/eight tokens and native
placement. Original Tech requires3+; Stronghold Cards require Advanced2+.
The separate original `leader-skills --discoveries` entry additionally
composes all fourteen Skills. Authorized PDF21–23 permits these variants.
Maker consequences/typed reserve rides, reveal/Collection, later parent
entry, nested shipment and battle rescue keep actual physical sources.
Industry pays only at phase end; held cards remain source-local and original
cleanup precedes mandatory winner Tech. See
[Homeworld runtime](HOMEWORLD_RUNTIME_IMPLEMENTATION.md#classic-optional-module-compositions--6-october-2026)
for145 affected tests, source-qualified games and actual human nested rescue
and Homeworld winner Tech choice. Nexus/native Homeworld rosters and unrelated
overlays remain separate in this bounded profile, not outside the full goal.
## Source and setup boundary
@@ -33,7 +35,7 @@ and standalone Ecaz **or** Moritani with classic opponents use their original
decks and native setup. Canonical unused Tech Tokens may be preserved at three
through six seats; original Stronghold Cards require Advanced. Advanced Moritani
retains its non-Harkonnen assassination profile; paired/mixed E3, Skills,
Homeworlds, Nexus and unrelated previews stay outside this entry.
native Homeworld combinations, Nexus and unrelated previews stay outside this entry.
Discoveries are enabled before components are dealt. The initializer adds the
seven cards to the selected original Spice Deck, retaining Ix Sandtrout, and
+62
View File
@@ -6,6 +6,68 @@ 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 optional-module compositions — 6 October 2026
The fresh classic Homeworld prototypes now compose **Skills, Discovery or
both**, with original unused Tech at three or more seats and/or original
Stronghold Cards in Advanced at two or more seats. Basic/Advanced two–six
unique base factions; both optional industry/card modules require Advanced3+.
Existing `leader-skills --discoveries` and `discovery` private room entries
preserve selected Homeworlds and original components; no parallel setup or
save conversion. The runner adds ten profiles at stable bands6600–7500 while
retaining existing6400/6500 names/seeds. Families `homeworld-skills`,
`homeworld-discovery` and `homeworld-discovery-skills` add `-tech`,
`-stronghold` or `-stronghold-tech` under those prerequisites.
Authorized PDF21–23 expressly permits variants together/separately. Original
all14 offers, DS7/8, first-Storm industry ownership, end-Mentat held cards and
native reserve placement remain. Nested battles get no Homeworld native
bonus. Typed Suk returns use actual homes; support is already paid before
rescue, while later Cistern income is a separate printed reward. Native saves
still stay at their original home; the external Imperial split remains the
implementation interpretation recorded below, not ordinary revival placement.
Printed industry accrues once and pays at phase end; Guild-only transport is
excluded. Held Arrakeen support and Carthag poison defense remain source-local,
not borrowed by Homeworlds or Cistern. Physical cleanup precedes mandatory
winner Tech choice without replaying armies or casualties.
New semantic cases14/14; affected union145/145 across17 files; types/lint
and build pass. Original private room entry also exercises real combinations
and rejects two-seat Tech/Basic Strongholds with the saved lobby unchanged.
Four-policy original six-seat games cover ten added profiles: thirteen
completed7879/7879 actions with207 JSON continuations on unchanged tree
`bf9b1f8a59f77494c1f2dcbd79326705aae1a00c37bc09bd3a157d4de9fb0851`.
One Advanced `homeworld-discovery-stronghold` seed20268015 stopped at
turn8/Shipment after1110/1110/29 because an internal view-copy assertion
treated four optional `undefined` advisor-response properties as different
from JSON omission. That incidental assertion was deleted, not re-pinned;
physical custody, rival-privacy checks and actual restored play remain.
The preserved capture completes19 further actions. The same original fresh
sample then completes1129/1129/30 on unchanged tree
`b47a9b126b4f7edf4a29a346fc124ee9427e22d7659c3f0762b0b16ad96fe2f2`.
Thus fourteen completed original samples total9008/9008/237 across two source
batches, **not** one final-tree certificate or fourteen uninterrupted passes
on the first tree. Reports/capture remain under
`/tmp/dune-homeworld-optional-*-20261006`; no rules guard was bypassed.
Human QA: original HTTP four-seat lobbies, real host module toggles, original
private CLI and explicit unplayed offer/card/token lotteries feed genuine
action programmes. `XJCJ3H3W`v17 actually submits trained Cistern Suk:
three ordinary remain inside, reserves17/5 and Tanks0; Kaitain11 ordinary/
1 Sardaukar, Salusa1/4. Separate Collection pays Cistern2; original industry
and held-card custody remain. `WM8E7CFG`v17 actually chooses Spice Production
from two real mandatory Tech options after two genuine Homeworld battles and
original winner cleanup. Emperor retains one ordinary visitor in Southern
Hemisphere, native home is empty, reserves14/4, Tanks2/0 and original parent
army3 with1 Sardaukar. The defeated Fremen retains Axlotl; physical armies
are not replaced by the Tech action. Both390px surfaces, choices and fresh
resulting state were observed. These were working-tree controls, not a pushed
revision/deployment claim. Both tabs closed; temporary human scripts removed.
Nexus, native expansion rosters, unrelated previews/variants and pending
specific rulings remain separate in this checkpoint. Public starts, full
combinations, comprehensive assurance, calibration and deployment stay open.
## Classic Skills and Discovery — 6 October 2026
Fresh private prototypes now support Homeworlds with **either** Leader Skills
+27
View File
@@ -1,5 +1,32 @@
# Dune implementation status
## 6 October 2026 — Classic Homeworld optional modules
Original classic Homeworld Skills/Discovery families now compose either or
both with original Tech3+ and Advanced Strongholds2+, under existing fresh
private entries. Original all14/DS7+8 setup, native placement, typed nested/
native/visitor Suk, phase-end industries, source-local held benefits and
cleanup before mandatory winner Tech remain; no redundant force/payment model.
Ten runner profiles retain stable old bands/names/seeds. Nexus/native
Homeworld rosters, unrelated previews and pending specific rulings stay separate.
New cases14/14, affected145/145 across17 files, types/lint/build pass.
Fourteen completed original six-seat samples total9008/9008 actions/237 JSON
across two unchanged source batches. One internal `undefined`-property view
copy assertion falsely stopped an Advanced original sample; that incidental
assertion was deleted, not re-pinned. Its preserved capture finishes19 more
actions, and the repaired original sample finishes1129/1129/30. Physical
custody, rival-privacy checks and real JSON continuation remain.
Actual human390px `XJCJ3H3W`v17 submits trained nested Suk and retains
Cistern3normal, reserves17/5, Tanks0, Kaitain11+1/Salusa1+4 and separate
Cistern2 income. `WM8E7CFG`v17 selects Spice Production after real Homeworld
cleanup, retaining one actual visitor, empty native home and reserves14/4,
Tanks2/0. Both tabs closed; temporary human scripts removed. Exact programme,
source hashes, capture and limitations:
[Homeworld runtime](HOMEWORLD_RUNTIME_IMPLEMENTATION.md#classic-optional-module-compositions--6-october-2026).
No full-mode, comprehensive assurance, calibration or deployment certification.
## 6 October 2026 — Classic Homeworld Skills and Discovery
Private fresh Basic/Advanced two–six unique base-faction profiles now compose
+19 -16
View File
@@ -7,18 +7,19 @@ 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.
Fresh Basic/Advanced two–six unique base-faction private skill prototypes
retain Homeworlds with all fourteen offers, optionally Discovery7/8,
original Tech3+ and Advanced Strongholds2+. Original training precedes
native placement. Native/visitor/nested Suk uses actual typed pools; native
saved counters remain at the same home. External Emperor return allocation
stays an implementation interpretation, not ordinary revival placement.
Support precedes rescue; later Cistern income and mandatory winner Tech are
separate original effects. Held cards stay source-local. Payment redirection,
original lifecycle and minimal legal bots remain connected. See
[Homeworld runtime](HOMEWORLD_RUNTIME_IMPLEMENTATION.md#classic-optional-module-compositions--6-october-2026)
for145 affected cases, exact game provenance and actual390px human nested
Suk submission. Nexus/native Homeworld rosters, unrelated overlays and
pending specific interactions remain separate; no public/full-mode gate changes.
## Native trained Skills/Nexus/Discovery — 6 October 2026
@@ -57,8 +58,9 @@ Actual Great Maker losses/votes/typed reserve rides, both Advanced piles and
settled alliance precede the original end-Spice closing choice, not Mentat.
Source-clear Emperor/Fremen free-return ledgers, original Axlotl settlement,
skilled nested battle and mandatory original-winner Tech reuse existing rules.
Native Skills/Nexus/Discovery, HW/variants/previews/public starts/conversion
and unresolved effects remain guarded.
Native Skills/Nexus/Discovery has separate bounded evidence above. Homeworlds
remain outside this Nexus profile; unrelated variants/previews, public starts,
conversion and unresolved effects stay guarded.
[Canonical combined evidence](DISCOVERY_PROTOTYPE.md#classic-skillsnexus-and-paired-native-nexus-discovery--6-october-2026)
records the169 affected union, eighteen genuine six-seat completions and three
@@ -78,8 +80,9 @@ and E3 trained death/assassination/own revival use original consumers.
Actual offered training and printed starting hands are not replaced.
The same supported live Ecaz profile keeps mandatory owner-labelled coalition
battles; shared Duke assignments and Basic odd-force rounding remain guarded.
Nexus/HW/variants/Banker/Mentat previews/public starts/save conversion stay
separate. This does not certify all fourteen effects or complete native factions.
Other Nexus profiles have separate bounded evidence above. Native Homeworlds,
unrelated variants, Banker/Mentat previews, public starts and save conversion
remain separate. This does not certify all fourteen effects or complete factions.
[The Discovery contract](DISCOVERY_PROTOTYPE.md#native-leader-skills-composition--6-october-2026)
records affected107, actual producer programmes and three original human
+4 -3
View File
@@ -5,9 +5,10 @@ Updated 2 October 2026. This is the authoritative navigation index for existing
## 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)
separately. Fresh bounded classic Homeworld profiles compose Skills, Discovery
or both, optionally original Tech3+ and Advanced Strongholds2+; no source claim
that these modules are generally incompatible remains.
[Homeworld runtime](HOMEWORLD_RUNTIME_IMPLEMENTATION.md#classic-optional-module-compositions--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
+9 -6
View File
@@ -17,12 +17,15 @@ 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.
Actual human trained mixed rescue, native/visitor/Arrakis and nested cases,
rejection boundaries and source-qualified games are recorded in
[Homeworld runtime](HOMEWORLD_RUNTIME_IMPLEMENTATION.md#classic-optional-module-compositions--6-october-2026).
The classic Homeworld profile optionally composes Discovery, original Tech3+
and Advanced Strongholds2+: support is paid before rescue, source-local
cards remain separate, original cleanup precedes Tech choice and surviving
Cistern forces collect its printed income independently. Advanced Atreides/KH,
Diplomat retreat, Emperor Face Dance allocation, Nexus/native Homeworld
rosters and unrelated overlays remain outside this bounded composition.
## Source contract
+5 -4
View File
@@ -9,11 +9,12 @@ export function discoveryModuleProfile(game: FactionModuleProfile): boolean {
discoveryModeSupported(game);
}
/** Original classic Homeworld/Discovery; live Arrakis-only token frames are expected. */
/** Original classic Homeworld/Discovery, optionally Tech/Advanced Strongholds. */
export function classicHomeworldDiscoveryProfile(game: FactionModuleProfile): boolean {
if (!game.homeworlds || typeof game.advanced !== 'boolean' || game.discoveryEnabled !== true ||
game.expansions.length || game.players.length < 2 || game.players.length > 6 ||
game.leaderSkills || game.nexusCards || game.techTokens || game.strongholdCards ||
game.leaderSkills || game.nexusCards || (game.techTokens && game.players.length < 3) ||
(game.strongholdCards && !game.advanced) ||
game.ecazTreachery || game.semutaPreview || game.advancedPreview || game.kullPreview ||
game.nexusKullPreview || game.guildBetrayalPreview || game.richeseBetrayalPreview ||
game.nexusIxianReplacementPreview || game.nexusIxianBetrayalPreview ||
@@ -66,14 +67,14 @@ export function discoveryModeSupported(game: FactionModuleProfile): boolean {
return (!e3 || native === 1) && !(game.advanced && moritani && harkonnen);
}
/** Fresh or live classic all-fourteen Skills/Discovery, optionally Tech/Advanced Strongholds.
/** Fresh or live classic all-fourteen Skills/Discovery, optionally Homeworlds/Tech/Advanced Strongholds.
* Physical undealt components and existing inventories belong to the initializer. */
export function classicDiscoveryLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
const players = game.players;
if (typeof game.advanced !== 'boolean' || game.discoveryEnabled !== true ||
game.expansions.length || !players || players.length < 2 || players.length > 6 ||
(game.techTokens && players.length < 3) || (game.strongholdCards && !game.advanced) ||
game.homeworlds || game.nexusCards || game.ecazTreachery || game.semutaPreview ||
game.nexusCards || game.ecazTreachery || game.semutaPreview ||
game.moritaniAssassinatePreview || game.advancedPreview || game.kullPreview ||
game.nexusKullPreview || game.guildBetrayalPreview || game.richeseBetrayalPreview ||
game.nexusIxianReplacementPreview || game.nexusIxianBetrayalPreview ||
+5 -4
View File
@@ -9538,7 +9538,7 @@ export function initializeLeaderSkillsGameForAudit(state: Game): Game {
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.');
'Homeworld Leader Skills require a fresh classic lobby with only supported original Discovery, Tech or Advanced Stronghold modules.');
requireFreshBaseRuntime(state);
requireFreshFactionInventory(state);
}
@@ -9587,8 +9587,9 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
homeworldShipmentIntegrity(state);
const g = structuredClone(state);
requireFreshSetup(g, homeworlds || nexus || ix || factions);
const homeworldSkills = leaderSkills && classicHomeworldLeaderSkillsProfile(g);
const skillStronghold = leaderSkills && strongholdLeaderSkillsProfile(g);
const skillTech = leaderSkills && (classicTechLeaderSkillsProfile(g) || nativeTechLeaderSkillsProfile(g) || skillStronghold || classicDiscoveryLeaderSkillsProfile(g));
const skillTech = leaderSkills && (classicTechLeaderSkillsProfile(g) || nativeTechLeaderSkillsProfile(g) || skillStronghold || homeworldSkills || classicDiscoveryLeaderSkillsProfile(g));
const nativeTech = factions && !leaderSkills && nativeFactionTechProfile(g);
const discoveryComposition = discovery && (discoveryModuleProfile(g) ||
classicDiscoveryNexusProfile(g) || pairedDiscoveryNexusProfile(g) ||
@@ -9598,7 +9599,7 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
requireRule(!g.techTokens || ((skillTech || nativeTech || nexusModules || discoveryComposition) &&
JSON.stringify(g.techTokens) === JSON.stringify(createTechTokens())),
'Tech Tokens require a fresh supported three-through-six-seat lobby with unused tokens.');
requireRule(!g.strongholdCards || strongholdFactions || ((skillStronghold || nexusModules || discoveryComposition) &&
requireRule(!g.strongholdCards || strongholdFactions || ((skillStronghold || homeworldSkills || nexusModules || discoveryComposition) &&
JSON.stringify(g.strongholdCards) === JSON.stringify(createStrongholdCards())),
'Stronghold Cards require a fresh supported Advanced lobby with unused cards.');
requireRule(
@@ -9610,7 +9611,7 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
(nexus || !g.nexusCards) &&
(ecazTreachery || !g.ecazTreachery) &&
(!g.techTokens || skillTech || nativeTech || nexusModules || discoveryComposition) &&
(strongholdFactions || skillStronghold || nexusModules || discoveryComposition || !g.strongholdCards) &&
(strongholdFactions || skillStronghold || homeworldSkills || nexusModules || discoveryComposition || !g.strongholdCards) &&
(homeworlds || !g.homeworlds) &&
g.players.every((p) =>
FACTIONS.some(
+5 -3
View File
@@ -376,13 +376,15 @@ export function nativeTechLeaderSkillsProfile(game: LeaderSkillProfile): boolean
return !!game.techTokens && nativeExpansionLeaderSkillsProfile(game);
}
/** Original classic Homeworld/Skills; custody and undealt components belong to setup. */
/** Original classic Homeworld/Skills, optionally Discovery/Tech/Advanced Strongholds. */
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.nexusCards ||
(game.discoveryEnabled !== true && (game.discoveryEnabled || game.discoveries || game.discoveryStash || game.greatMaker)) ||
(game.techTokens && players.length < 3) || (game.strongholdCards && !game.advanced) ||
game.ecazTreachery ||
game.semutaPreview || game.moritaniAssassinatePreview || game.advancedPreview ||
game.kullPreview || game.nexusKullPreview || game.guildBetrayalPreview ||
game.richeseBetrayalPreview || game.nexusIxianReplacementPreview ||
+12 -8
View File
@@ -3820,7 +3820,7 @@ export const RULE_TOPICS: RuleTopic[] = [
).join(' '),
steps: [
'Homeworlds are an optional module from Ecaz & Moritani, available under the official rules with Basic or Advanced play. They are separate locations outside Arrakis; they do not receive ordinary storm movement or count as ordinary strongholds.',
'Fresh private classic Homeworld prototypes 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.',
'Fresh private classic Homeworld prototypes compose all fourteen Leader Skills, Discovery seven cards/eight tokens or both, Basic/Advanced two through six unique base factions. Optional original Tech requires three or more seats; Stronghold Cards require Advanced. Original setup/native placement, phase-end industry, source-local held cards and cleanup before mandatory winner Tech remain. Typed native Suk saves stay at the same home; external Emperor returns explicitly allocate normal/elite counters to actual native homes, an implementation interpretation rather than ordinary revival placement. Nested battles receive no Homeworld bonus; later Cistern income is separate from support/rescue. Nexus/native rosters, unrelated overlays, public starts and pending interactions stay separate.',
'Each faction uses its own card and matching world token. In Basic play the Emperor uses Kaitain alone. Advanced play adds Salusa Secundus for its Sardaukar; movement can transfer both force types between the two Emperor worlds.',
'Native reserves determine high or low population. Salusa counts Sardaukar, while the other cards count the faction’s reserves there. Reaching the high face’s minimum restores high population. Occupation also applies the low penalty.',
'Low population normally grants one additional free force revival and one additional bank spice when collecting CHOAM Charity. Salusa grants neither bonus; CHOAM’s Tupile reverses the usual pattern by granting an advantage while low and a penalty while high.',
@@ -3984,8 +3984,12 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Verification',
status: 'Partial',
detail:
'Original setup, typed transfers, native payments, occupied percentages/Bidding/private-lot binding and preserved BG accompaniment have consumer regressions and actual390px controls. The bounded profile completes44/50 genuine samples; four Basic occupation/two CHOAM ruling captures remain. Five original Advanced captures resume at their original seeds, and five fresh six-seat Advanced games finish after the advisor repair. Full Homeworld rules, all combinations, final assurance and deployed acceptance remain unverified.',
'Classic Homeworld Skills/Discovery or both optionally retain Tech/Advanced Strongholds:145 affected cases, typed native/visitor/nested rescue, phase-end industries, source-local held effects and cleanup before winner Tech. Fourteen completed original samples9008/9008 actions/237 JSON span two source batches after deleting an incidental undefined-key copy assertion; its original capture and actual continuation remain. Human390px nested Suk and native-world winner Tech choices retain actual armies. Earlier setup, typed transfers, native payments, occupied percentages/Bidding/private-lot binding and BG accompaniment evidence remains. The earlier bounded occupation profile completes44/50 samples; four Basic occupation/two CHOAM ruling captures remain, alongside five resumed and five fresh Advanced advisor-repair games. Full rules, all combinations, final assurance and deployed acceptance remain unverified.',
evidence: [
'tests/homeworld-classic-skills-runtime.test.ts',
'tests/homeworld-classic-discovery-runtime.test.ts',
'tests/homeworld-skills-discovery-runtime.test.ts',
'tests/homeworld-optional-modules-runtime.test.ts',
'tests/homeworld-custody.test.ts',
'tests/homeworld-cards.test.ts',
'tests/homeworld-payment-income.test.ts',
@@ -4081,8 +4085,8 @@ export const RULE_TOPICS: RuleTopic[] = [
searchText: 'Hiereg Smuggler Jacurutu Sietch Cistern Ecological Testing Station Shrine Orgiz Processing Station Treachery Card Stash Spice Stash Ornithopter',
steps: [
'Fresh Discovery development setup supports classic, selected Ixian/Tleilaxu/CHOAM/Richese families, or standalone Ecaz or Moritani with classic opponents in Basic or Advanced, with seven extra Spice Cards and eight tokens. Original unused Tech Tokens may be selected with three through six seats; original Stronghold Cards require Advanced. Other modules, paired or mixed Ecaz/Moritani families and normal public starts stay gated.',
'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.',
'Fresh classic Discovery retains Homeworlds in Basic/Advanced two through six unique base factions, original seven cards/eight tokens and native reserve placement. Original Tech requires three or more seats; Stronghold Cards require Advanced. The separate original leader-skills --discoveries entry also composes all fourteen Skills. Maker, later parent entry, nested shipment/rescue, phase-end industry and cleanup before mandatory winner Tech retain actual sources; held benefits never spill into a Homeworld or unrelated nested site. Nexus/native Homeworld rosters and unrelated overlays remain separate in this bounded profile, not outside the full goal.',
'The fresh Leader Skills entry may explicitly add Discoveries with original classic or supported native decks, all fourteen skills, original Tech at three or more seats and Advanced Stronghold Cards. Classic profiles may retain Homeworlds without Nexus. --nexus-cards additionally retains all12 with the actual settled-alliance end-Spice closing deal after Great Maker and both Advanced piles. Existing native roster and Duke/assassination/Harkonnen restrictions remain. Actual hands/offers, physical entry, nested tariffs, stash capacity, typed rescue, native aftermath and printed invoices retain their sources. Homeworlds with Nexus or native rosters, independent variants and separate Banker/Mentat previews remain outside these bounded compositions.',
'The same fresh entry also adds --nexus-cards --discoveries to paired Ixians+Tleilaxu, paired CHOAM+Richese or standalone Ecaz OR Moritani with classics: original all14 offered training, all12 Nexus and Discovery7/8, Basic/Advanced two through six, optional Tech at three or more seats and Advanced Strongholds. Original setup, first hands, offers and native components stay authoritative. Real Maker losses/votes/typed rides, both Advanced piles and settled alliance precede the end-Spice closing draw. E1 Suboid Cunning settles typed Suk rescue before one equal Cyborg Tank substitution; original cleanup and mandatory winner Tech precede a matching Face Dance. E2 keeps the signed two-marker frame and one invoice. Ecaz keeps quiet living/unclaimed Duke separate from training and a real owner-labelled mandatory Occupy; Moritani keeps skill-first assassination and one private Mentat replacement. Mixed/E3 pairs, Homeworlds, shared Duke, borrowed Smuggler/pair companions, native free returns, captured/foreign training, Basic odd Ecaz and Advanced Harkonnen stay separate.',
'A separate fresh Nexus entry may explicitly add Discoveries to classic factions or both native Ixians/Tleilaxu or CHOAM/Richese with classics, one original family deck and no Skills. Original Tech requires three or more seats; Stronghold Cards require Advanced. End-Mentat ownership and next-turn physical entry remain. Great Maker finishes its losses, votes and typed reserve ride before ordinary Nexus settlement; cards are offered only at the end of the entire Spice Blow after both Advanced piles and settled alliance, not immediately or at Mentat. Source-clear return ledgers, Guild-priced nested shipment and signed native one-invoice marker pairs retain physical pieces and original capacities. E3/mixed families, Homeworlds, unrelated previews and unresolved mixed-module Fremen Betrayal stay separate from this no-Skills entry.',
'Original Stronghold Cards are claimed only at end-Mentat. A held benefit applies in its named territory or the mobile stronghold through its actual declared copy; revealed Discovery locations do not inherit another territory’s defense or bank support. Jacurutu counts toward stronghold victory but creates no seventh Stronghold Card. Physical free entry, nested tariffs, trained or native force casualties, card cleanup and optional Tech reward retain their original sources.',
@@ -4124,8 +4128,8 @@ export const RULE_TOPICS: RuleTopic[] = [
},
{
area: 'Verification', status: 'Partial',
detail: 'Original native/skill/Nexus programmes and phone controls prove physical entry, paid tariffs/No-Field, stash, trained/typed rescue, HMS support, CHOAM invoices, source-local defense and native leader/reward transitions. All31 native skill six-seat samples complete after three reproduced/repaired/resumed hidden-Smuggler policy captures, across two source trees. Earlier classic/Nexus/Stronghold/native proofs and the exhausted-cache ruling capture remain separate. These are targeted proofs, not every-effect, full combinations, assurance or deployed acceptance.',
evidence: ['tests/discovery-native-e1-skills-runtime.test.ts', 'tests/discovery-native-e2-skills-runtime.test.ts', 'tests/discovery-native-e3-skills-runtime.test.ts', 'tests/discovery-skills-strongholds-runtime.test.ts', 'tests/discovery-native-strongholds-runtime.test.ts', 'tests/ecaz-stronghold-runtime.test.ts', 'tests/e3-native-tech-runtime.test.ts', 'tests/discovery-classic-nexus-lifecycle.test.ts', 'tests/discovery-classic-nexus-effects.test.ts', 'tests/discovery-orgiz-advanced-runtime.test.ts', 'tests/discovery-classic-skills-runtime.test.ts', 'tests/discovery-module-profile.test.ts', 'tests/discovery-native-typed-runtime.test.ts', 'tests/discovery-native-marker-runtime.test.ts', 'tests/discovery-native-e3-runtime.test.ts', 'tests/discoveries.test.ts', 'tests/discovery-runtime.test.ts', 'tests/great-maker.test.ts', 'tests/discovery-board.test.ts', 'tests/discovery-admission.test.ts', 'tests/discovery-controls.test.ts', 'tests/discovery-entry.test.ts', 'tests/discovery-entry-runtime.test.ts', 'tests/discovery-entry-controls.test.ts', 'tests/discovery-entry-review.test.ts', 'tests/discovery-collection.test.ts', 'tests/discovery-flight.test.ts', 'tests/discovery-flight-engine.test.ts', 'tests/discovery-flight-recovery.test.ts', 'tests/discovery-battle.test.ts', 'tests/discovery-battle-recovery.test.ts', 'tests/discovery-storm.test.ts', 'tests/discovery-storm-recovery.test.ts', 'tests/discovery-shrine-engine.test.ts', 'tests/discovery-shrine-recovery.test.ts'],
detail: 'Classic Homeworld Discovery with optional all14 Skills/Tech/Advanced Strongholds adds original Maker, parent entry, typed nested rescue, phase-end industry, source-local held cards and cleanup before native-world winner Tech. Affected145 cases and fourteen completed samples9008/9008 actions/237 JSON span two source batches after an incidental undefined-key copy assertion was deleted; original capture/continuation are preserved. Human390px nested Suk and mandatory Tech choices retain real armies. Earlier native/skill/Nexus phone programmes prove entry, tariffs/No-Field, stash, HMS support, CHOAM invoices and native transitions;31 native skill samples complete after three hidden-Smuggler repairs across two trees. Earlier proofs and the exhausted-cache ruling capture remain separate. Not every-effect/full combinations, assurance or deployed acceptance.',
evidence: ['tests/homeworld-classic-discovery-runtime.test.ts', 'tests/homeworld-skills-discovery-runtime.test.ts', 'tests/homeworld-optional-modules-runtime.test.ts', 'tests/discovery-native-e1-skills-runtime.test.ts', 'tests/discovery-native-e2-skills-runtime.test.ts', 'tests/discovery-native-e3-skills-runtime.test.ts', 'tests/discovery-skills-strongholds-runtime.test.ts', 'tests/discovery-native-strongholds-runtime.test.ts', 'tests/ecaz-stronghold-runtime.test.ts', 'tests/e3-native-tech-runtime.test.ts', 'tests/discovery-classic-nexus-lifecycle.test.ts', 'tests/discovery-classic-nexus-effects.test.ts', 'tests/discovery-orgiz-advanced-runtime.test.ts', 'tests/discovery-classic-skills-runtime.test.ts', 'tests/discovery-module-profile.test.ts', 'tests/discovery-native-typed-runtime.test.ts', 'tests/discovery-native-marker-runtime.test.ts', 'tests/discovery-native-e3-runtime.test.ts', 'tests/discoveries.test.ts', 'tests/discovery-runtime.test.ts', 'tests/great-maker.test.ts', 'tests/discovery-board.test.ts', 'tests/discovery-admission.test.ts', 'tests/discovery-controls.test.ts', 'tests/discovery-entry.test.ts', 'tests/discovery-entry-runtime.test.ts', 'tests/discovery-entry-controls.test.ts', 'tests/discovery-entry-review.test.ts', 'tests/discovery-collection.test.ts', 'tests/discovery-flight.test.ts', 'tests/discovery-flight-engine.test.ts', 'tests/discovery-flight-recovery.test.ts', 'tests/discovery-battle.test.ts', 'tests/discovery-battle-recovery.test.ts', 'tests/discovery-storm.test.ts', 'tests/discovery-storm-recovery.test.ts', 'tests/discovery-shrine-engine.test.ts', 'tests/discovery-shrine-recovery.test.ts'],
},
],
},
@@ -4644,7 +4648,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.',
'Fresh classic Leader Skills retains Homeworlds in Basic/Advanced two through six unique base factions and all fourteen original offers, optionally original Discovery7/8, Tech at three or more seats and Advanced Stronghold Cards. Native placement follows training. Normal/trained Suk uses actual native, visitor or nested physical pools; native saved counters remain at that home. External Emperor returns explicitly allocate ordinary/elite counters to actual native homes, an implementation interpretation rather than ordinary revival placement. Support precedes rescue; later Cistern income, source-local held effects and mandatory winner Tech after original cleanup stay separate. Bureaucrat invoices and minimal legal bots are connected. Nexus/native Homeworld rosters, unrelated overlays and pending interactions stay separate.',
'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.',
@@ -4682,7 +4686,7 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area:'Player controls',status:'Partial',detail:'Private card/leader selection, readable inspectors, public battle posture and revival draw/decline, Planetologist range/gather controls, Special weapon selection and Suk Graduate physical rescue choices are connected. Rihani has private history and separate keep-new/reveal-old controls; automatic battle effects need no confirmation. Smuggler has explicit ordinary shipping opt-out and No-Field companion opt-in with physical-versus-priced force counts, plus automatic surviving-leader battle collection with reveal-time guidance. Banker exposes separate sealed spending and authorized inspection. Sandmaster exposes explicit legal routes, per-territory collection choices and an optional worm-destination checkbox. Diplomat offers a named committed Worthless choice or decline after public reveal, and a separate surviving-loser destination/normal/elite retreat or decline. Bureaucrat offers redirect or full payment with explicit recipient amounts and deferred bribe custody. Remaining effect controls are missing.',evidence:['components/leader-skills.tsx','components/planetologist-movement.tsx','components/suk-graduate.tsx','components/rihani-decipherer.tsx','components/mentat-question.tsx','components/bureaucrat-payment.tsx','components/leader-skill-battle-guide.tsx','components/smuggler-shipment.tsx','components/spice-banker.tsx','components/sandmaster-movement.tsx','components/sandmaster-worm.tsx','components/diplomat-defense.tsx','components/diplomat-retreat.tsx','components/game-table.tsx'] },
{ area:'AI',status:'Partial',detail:'All four profiles use private offers, legal visibility/revival actions and Planetologist movement/battle candidates and legal Suk Graduate rescues, Rihani exchanges, shared battle scoring, Smuggler reserve/owned No-Field shipments and unmodified collection plans, Sandmaster route and worm collection, funded Banker commitments and optional Diplomat copying from public revealed defenses or choosing legal losing-battle retreat. Bureaucrat uses the public payee alliance to redirect enemy income or preserve allied income. These are legal prototype paths; complete skill strategy and difficulty calibration remain unfinished.',evidence:['game/bots.ts'] },
{ area:'Documentation',status:'Partial',detail:'All fourteen faces and common rules are recorded. Skilled capture follows public physical-card custody; replacement entitlement and other material timing interpretations remain explicit.',evidence:['docs/LEADER_SKILLS_RULES.md','docs/LEADER_SKILLS_CAPTURE.md','docs/LEADER_SKILLS_RUNTIME.md','docs/PLANETOLOGIST_RULES.md','docs/SUK_GRADUATE_RULES.md','docs/LEADER_BATTLE_EFFECTS.md','docs/MENTAT_QUESTION.md','docs/BUREAUCRAT_PAYMENTS.md','docs/SMUGGLER_SHIPMENT.md','docs/SPICE_BANKER_RUNTIME.md','docs/SANDMASTER_WORM.md','docs/DIPLOMAT_DEFENSE.md'] },
{ area:'Verification',status:'Partial',detail:'Dated classic/native Skills-Tech and E1/E2 Stronghold evidence remains in the existing runtime. Standalone E3 adds15 meaningful cases,152 affected, types/lint/build, nine actual programs and eight E3 games (1531 actions/no rejection,38 JSON continuations); expanded47-game batch13936 actions/358 continuations. Human390px normal Warmaster1/Duke6/Carthag Shield wins7–6 and releases the living Duke; normal Guild Suk precedes unused Master3 death/printed bounty, original winner Tech and one private Caid at Mentat. Actual physical force/card outcomes and separately quoted Collection remain on refresh. Full combinations, assurance, public and deployed acceptance stay open.',evidence:['tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/leader-battle-effects.test.ts','tests/suk-graduate.test.ts','tests/mentat-question.test.ts','tests/bureaucrat-payment.test.ts','tests/smuggler-battle.test.ts','tests/spice-banker.test.ts','tests/advanced-native-skills.test.ts','tests/fixture-advanced-native-skills.ts','tests/skills-tech-payments-runtime.test.ts','tests/skills-tech-battle-runtime.test.ts','tests/ix-choam-skills-tech-runtime.test.ts','tests/tleilaxu-skills-tech-runtime.test.ts','tests/richese-skills-tech-runtime.test.ts','tests/classic-skills-stronghold-runtime.test.ts','tests/native-skills-stronghold-runtime.test.ts','tests/ecaz-skills-modules-runtime.test.ts','tests/moritani-skills-modules-runtime.test.ts','docs/LEADER_SKILLS_RUNTIME.md'] },
{ area:'Verification',status:'Partial',detail:'Classic Homeworld Skills optionally composes Discovery/Tech/Advanced Strongholds:145 affected cases, original nested/native/visitor rescue and physical industry/held-card/reward ordering. Fourteen completed samples9008/9008 actions/237 JSON span two source batches after an incidental undefined-key copy assertion was deleted; original capture/continuation remain. Human390px typed nested Suk and Homeworld winner Tech choice retain actual armies. Dated classic/native Skills-Tech and E1/E2 Stronghold evidence remains. Standalone E3 adds15 meaningful cases,152 affected, types/lint/build, nine actual programs and eight games1531 actions/38 continuations; expanded47-game batch13936/358. Human normal Warmaster1/Duke6/Carthag Shield wins7–6/releases Duke; Guild Suk precedes unused Master3 death/bounty, winner Tech and one private Caid. Full combinations, assurance, public/deployed acceptance stay open.',evidence:['tests/homeworld-classic-skills-runtime.test.ts','tests/homeworld-skills-discovery-runtime.test.ts','tests/homeworld-optional-modules-runtime.test.ts','tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/leader-battle-effects.test.ts','tests/suk-graduate.test.ts','tests/mentat-question.test.ts','tests/bureaucrat-payment.test.ts','tests/smuggler-battle.test.ts','tests/spice-banker.test.ts','tests/advanced-native-skills.test.ts','tests/fixture-advanced-native-skills.ts','tests/skills-tech-payments-runtime.test.ts','tests/skills-tech-battle-runtime.test.ts','tests/ix-choam-skills-tech-runtime.test.ts','tests/tleilaxu-skills-tech-runtime.test.ts','tests/richese-skills-tech-runtime.test.ts','tests/classic-skills-stronghold-runtime.test.ts','tests/native-skills-stronghold-runtime.test.ts','tests/ecaz-skills-modules-runtime.test.ts','tests/moritani-skills-modules-runtime.test.ts','docs/LEADER_SKILLS_RUNTIME.md','docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md'] },
],
},
{
+6 -7
View File
@@ -55,13 +55,12 @@ void test('Discovery cannot redeal started components or silently remove another
void test('Discovery Skills cannot silently drop unsupported native families or unrelated modules and previews', () => {
const unsupported = lobby(['ixians', 'ecaz', 'guild'], ['ix', 'ecaz']);
assert.throws(() => initializeLeaderSkillsGameForAudit(unsupported), RuleError);
const overlays = Array.from({ length: 5 }, () => lobby(['guild', 'emperor', 'harkonnen'], []));
overlays[0].homeworlds = { custody: null };
overlays[1] = lobby(['ixians', 'choam', 'guild'], ['ix', 'choam']);
overlays[1].nexusCards = { cards: null, phase: null };
overlays[2].advanced = false; overlays[2].strongholdCards = createStrongholdCards();
overlays[3].mentatQuestionPreview = true;
overlays[4].spiceBankerIncomePreview = true;
const overlays = Array.from({ length: 4 }, () => lobby(['guild', 'emperor', 'harkonnen'], []));
overlays[0] = lobby(['ixians', 'choam', 'guild'], ['ix', 'choam']);
overlays[0].nexusCards = { cards: null, phase: null };
overlays[1].advanced = false; overlays[1].strongholdCards = createStrongholdCards();
overlays[2].mentatQuestionPreview = true;
overlays[3].spiceBankerIncomePreview = true;
for (const game of overlays) assert.throws(() => initializeLeaderSkillsGameForAudit(game), RuleError);
const two = lobby(['guild', 'emperor'], []); two.techTokens = createTechTokens();
assert.throws(() => initializeLeaderSkillsGameForAudit(two), RuleError);
+173
View File
@@ -0,0 +1,173 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeDiscoveryGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game,
} from '../game/engine';
import type { FactionId } from '../game/catalog';
import { treacheryDeck } from '../game/cards';
import { nativeShipmentSources } from '../game/homeworld-options';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import {
createHomeworldClassicDiscoveryFixture, homeworldClassicDiscoveryClean,
homeworldClassicDiscoveryPlayer, nextHomeworldClassicDiscoveryStep,
type HomeworldClassicDiscoveryFixture,
} from './fixture-homeworld-classic-discovery';
export type HomeworldOptionalStep = { actor: string; action: Action };
export type HomeworldOptionalOptions = {
initial?: Game;
advanced?: boolean;
seats?: 2 | 3 | 4 | 5 | 6;
tech?: boolean;
strongholds?: boolean;
};
export type HomeworldOptionalFixture = HomeworldClassicDiscoveryFixture & {
beforeFirstStorm: Game;
afterFirstStorm: Game;
};
export const optionalHomeworldPlayer = homeworldClassicDiscoveryPlayer;
export const optionalHomeworldClean = homeworldClassicDiscoveryClean;
/** Only original undealt card lotteries are chosen. The first seven random
* draws shuffle the eight opaque Discovery faces; the following shuffle is the
* original 33-card Treachery deck. Starting cards are dealt by native setup. */
function originalStartingLottery<T>(game: Game, initialize: () => T): T {
const cards = treacheryDeck(), remaining = [...cards];
const prefix = game.players.flatMap(player => {
const kinds = player.faction === 'emperor' ? ['poison'] : player.faction === 'harkonnen'
? ['shield', 'worthless'] : player.faction === 'atreides' ? ['shield'] : ['worthless'];
return kinds.map(kind => {
const index = remaining.findIndex(card => card.kind === kind); assert.ok(index >= 0);
return remaining.splice(index, 1)[0];
});
});
const desired = [...prefix, ...remaining], working = [...cards], rolls: number[] = [];
for (let i = working.length - 1; i > 0; i--) {
const j = working.findIndex(card => card.id === desired[i].id); assert.ok(j >= 0 && j <= i);
rolls.push(Math.floor((j + 0.5) / (i + 1) * 0x100000000));
[working[i], working[j]] = [working[j], working[i]];
}
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const original = crypto.getRandomValues.bind(crypto); let draw = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1) {
if (draw < 7) array[0] = 0xffffffff;
else if (draw < 7 + rolls.length) array[0] = rolls[draw - 7];
else Reflect.apply(original, crypto, [array]);
draw++;
} else Reflect.apply(original, crypto, [array]);
return array;
};
try { return initialize(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** Original fresh no-Skills lobby, optional unused physical components, original
* Homeworld/Discovery initializer. No owners, balances, forces or held cards are
* injected; even the optional components are selected by their host controls. */
export function createHomeworldOptionalLobby(options: HomeworldOptionalOptions = {}): Game {
let game = options.initial ? structuredClone(options.initial)
: createGame('HWOPTIONAL', newPlayer('emperor', 'Emperor', 'emperor'), options.advanced ?? true);
if (!options.initial) {
const roster: FactionId[] = ['fremen', 'guild', 'atreides', 'harkonnen', 'beneGesserit'];
for (const faction of roster.slice(0, (options.seats ?? 5) - 1)) joinGame(game, newPlayer(faction, faction, faction));
}
assert.ok(game.status === 'lobby' || (game.status === 'setup' &&
(game.setupStage === 'prediction' || game.setupStage === 'traitors') &&
game.turn === 1 && game.phase === 0 && !game.leaderSkills &&
game.discoveryEnabled && game.discoveries && game.homeworlds?.custody === null),
'Continue only an original fresh lobby or its initial Homeworld Discovery setup.');
if (game.status === 'setup') return game;
if (!game.homeworlds) game = applyAction(game, game.host, { type: 'homeworlds', enabled: true });
if (options.tech && !game.techTokens) game = applyAction(game, game.host, { type: 'techTokens', enabled: true });
if (options.strongholds && !game.strongholdCards) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: true });
game.discoveryEnabled = true;
return game;
}
export function initializeHomeworldOptional(options: HomeworldOptionalOptions = {}): Game {
let game = createHomeworldOptionalLobby(options);
if (game.status === 'setup') return game;
for (const seat of game.players) if (!seat.ready) game = applyAction(game, seat.id, { type: 'ready' });
return originalStartingLottery(game, () => initializeDiscoveryGameForAudit(game));
}
export function nextHomeworldOptionalStep(game: Game): HomeworldOptionalStep {
if (!game.phaseOpening && !game.response) {
if (game.decision?.kind === 'battleCards') return { actor: game.decision.player, action: { type: 'decision', discard: [] } };
if (game.decision?.kind === 'techToken') return { actor: game.decision.player,
action: { type: 'decision', token: game.decision.choices[0] } };
}
return nextHomeworldClassicDiscoveryStep(game);
}
export function actHomeworldOptional(game: Game, step: HomeworldOptionalStep, actions?: HomeworldOptionalStep[]): Game {
actions?.push(structuredClone(step)); return applyAction(game, step.actor, step.action);
}
export function advanceHomeworldOptional(state: Game, until: (game: Game) => boolean,
actions?: HomeworldOptionalStep[]): Game {
let game = state;
for (let i = 0; !until(game) && i < 1800; i++) {
assert.notEqual(game.status, 'finished');
game = actHomeworldOptional(game, nextHomeworldOptionalStep(game), actions);
}
assert.ok(until(game), 'The original optional Homeworld action window was not reached.');
return game;
}
export function createHomeworldOptionalFixture(options: HomeworldOptionalOptions = {}): HomeworldOptionalFixture {
const setup = initializeHomeworldOptional(options);
const beforeFirstStorm = advanceHomeworldOptional(setup, game => game.status === 'playing' &&
game.phase === 0 && optionalHomeworldClean(game));
const afterFirstStorm = advanceHomeworldOptional(beforeFirstStorm, game => game.phase === 1 && optionalHomeworldClean(game));
const fixture = createHomeworldClassicDiscoveryFixture({ initial: setup, prepareImperialSplit: true });
return { ...fixture, beforeFirstStorm, afterFirstStorm };
}
export function optionalHomeworldMovementWindow(state: Game, actor: string, turn = 2): Game {
return advanceHomeworldOptional(state, game => game.turn === turn && game.phase === 5 &&
game.active === actor && optionalHomeworldClean(game));
}
export function settleHomeworldOptionalArrival(state: Game): Game {
return advanceHomeworldOptional(state, game => !game.pendingShipment && !game.pendingHomeworldShipment && optionalHomeworldClean(game));
}
export function shipHomeworldOptional(game: Game, actor: string, territory: string, sector: number,
amount: number, elite = 0, sources = nativeShipmentSources(viewGame(game, actor), amount, elite)): Game {
assert.ok(sources);
return settleHomeworldOptionalArrival(applyAction(game, actor,
{ type: 'ship', territory, sector, amount, elite, allyPayment: 0, homeworldSources: sources }));
}
export function invadeHomeworldOptional(game: Game, actor: string, destination: string, amount: number): Game {
const native = nativeShipmentSources(viewGame(game, actor), amount, 0); assert.ok(native);
const sources = Object.fromEntries(Object.entries(native).filter(([, group]) => group.normal + group.elite > 0));
const choice = homeworldShipmentChoice(viewGame(game, actor), destination, sources); assert.ok(choice.action, choice.blocked ?? undefined);
return settleHomeworldOptionalArrival(applyAction(game, actor, choice.action));
}
export function openHomeworldOptionalBattle(state: Game, territory: string, first: string, second: string): Game {
let game = advanceHomeworldOptional(state, game => game.phase === 6 && !!game.active && optionalHomeworldClean(game));
const actor = game.active!; assert.ok(actor === first || actor === second);
game = applyAction(game, actor, { type: 'chooseBattle', territory, target: actor === first ? second : first });
return advanceHomeworldOptional(game, game => !!game.battle && !game.battle.preparation && optionalHomeworldClean(game));
}
export function optionalHomeworldLeader(game: Game, actor: string, strongest: boolean): string {
const leader = optionalHomeworldPlayer(game, actor).leaders.filter(disc => !disc.dead &&
(!disc.usedAt || disc.usedAt === game.battle?.territory))
.sort((a, b) => strongest ? b.strength - a.strength : a.strength - b.strength)[0];
assert.ok(leader); return leader.id;
}
export function finishHomeworldOptionalBattle(state: Game): Game {
const territory = state.battle!.territory;
return advanceHomeworldOptional(state, game =>
!game.battle && !game.homeworldBattleLoss && optionalHomeworldClean(game) && game.lastBattleContext?.territory === territory);
}
export type HomeworldOptionalClaim = { before: Game; step: HomeworldOptionalStep; after: Game };
export function claimHomeworldOptional(state: Game): HomeworldOptionalClaim {
let game = advanceHomeworldOptional(state, game => game.phase === 8 && optionalHomeworldClean(game));
const turn = game.turn;
for (let i = 0; i < 100; i++) {
const before = structuredClone(game), step = nextHomeworldOptionalStep(game);
game = actHomeworldOptional(game, step);
if (game.turn !== turn || game.status === 'finished') return { before, step, after: game };
if (game.strongholdCards?.claimedTurn === turn) return { before, step, after: game };
}
throw Error('Original end-Mentat did not settle its Stronghold Cards.');
}
+256
View File
@@ -0,0 +1,256 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
import type { FactionId } from '../game/catalog';
import type { LeaderSkillId } from '../game/leader-skill-cards';
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 { nativeShipmentSources } from '../game/homeworld-options';
import type { SukReserveDestinations } from '../game/suk-graduate';
import {
assertClassicHomeworldSkillsCustody, classicHomeworldSkillsPlayer,
createClassicHomeworldSkillsSetup, nextClassicHomeworldSkillsStep,
type ClassicHomeworldSkillsSetup, type ClassicHomeworldSkillsStep,
} from './fixture-homeworld-classic-skills';
export type HomeworldSkillsDiscoveryStep = ClassicHomeworldSkillsStep;
export type HomeworldSkillsDiscoveryOptions = {
initial?: Game;
advanced?: boolean;
seats?: 2 | 3 | 4 | 5 | 6;
roster?: readonly FactionId[];
skill?: LeaderSkillId;
face?: DiscoveryLocationId;
shipForMaker?: boolean;
};
export type HomeworldSkillsDiscoveryFixture = ClassicHomeworldSkillsSetup & {
game: Game;
owner: string;
fremen: string;
opponent: string | null;
trainer: string;
token: DiscoveryOpaqueTokenId;
face: DiscoveryLocationId;
parent: string;
parentSector: number;
wormTerritory: string;
wormSector: number;
amount: number;
elite: number;
beforeShipment: Game;
afterShipment: Game;
};
export type HomeworldSkillsDiscoveryBattleFixture = HomeworldSkillsDiscoveryFixture & {
entered: Game;
beforeReinforcement: Game;
afterReinforcement: Game;
beforeBattle: Game;
plans: HomeworldSkillsDiscoveryStep[];
};
export const homeworldSkillsDiscoveryPlayer = classicHomeworldSkillsPlayer;
export const homeworldSkillsDiscoveryClean = (game: Game) =>
!game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
/** An original fresh lobby, never a second reserve or component model. */
export function createHomeworldSkillsDiscoveryLobby(options: HomeworldSkillsDiscoveryOptions = {}): Game {
if (options.initial) return structuredClone(options.initial);
const factions: readonly FactionId[] = options.roster ?? ['emperor', 'fremen', 'guild', 'harkonnen', 'atreides', 'beneGesserit'];
let game = createGame('HWSKILLSDISCOVERY', newPlayer(factions[0], factions[0], factions[0]), options.advanced ?? false);
for (const faction of factions.slice(1, options.seats ?? (options.roster ? factions.length : 4))) joinGame(game, newPlayer(faction, faction, faction));
game = applyAction(game, game.host, { type: 'homeworlds', enabled: true });
game.discoveryEnabled = true;
return game;
}
export function createHomeworldSkillsDiscoverySetup(options: HomeworldSkillsDiscoveryOptions = {}): ClassicHomeworldSkillsSetup {
return createClassicHomeworldSkillsSetup({ initial: createHomeworldSkillsDiscoveryLobby(options), skill: options.skill ?? 'suk-graduate' });
}
/** Consume only original owner-labelled queues; human plans and rescue choices
* remain explicit boundaries. The lottery helper below never signs a window. */
export function nextHomeworldSkillsDiscoveryStep(game: Game): HomeworldSkillsDiscoveryStep | null {
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 } };
}
return nextClassicHomeworldSkillsStep(game, game.players.find(p => p.faction === 'emperor')?.id);
}
export function advanceHomeworldSkillsDiscovery(state: Game, until: (game: Game) => boolean,
actions?: HomeworldSkillsDiscoveryStep[]): Game {
let game = state;
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const next = nextHomeworldSkillsDiscoveryStep(game);
assert.ok(next, 'A real sealed plan or physical Suk rescue choice is required.');
actions?.push(structuredClone(next)); game = applyAction(game, next.actor, next.action);
}
throw Error('Original Homeworld/Skills/Discovery programme did not reach its owned window.');
}
export function assertHomeworldSkillsDiscoveryInventory(game: Game): void {
assertClassicHomeworldSkillsCustody(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);
}
function frontUnplayedSpice(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, 'Select only an original still-unplayed physical Spice Card.');
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
}
function originalTokenLottery<T>(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 descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'), native = crypto.getRandomValues.bind(crypto);
crypto.getRandomValues = <V extends ArrayBufferView | null>(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 act(game: Game, step: HomeworldSkillsDiscoveryStep, actions: HomeworldSkillsDiscoveryStep[]): Game {
actions.push(structuredClone(step)); return applyAction(game, step.actor, step.action);
}
/** PDF21–23 variants together: original all14 training before faction setup,
* original seven Spice Cards/eight tokens, and original Homeworld withdrawals.
* Only undealt lotteries/unplayed Spice order are selected; no force staging. */
export function createHomeworldSkillsDiscoveryFixture(options: HomeworldSkillsDiscoveryOptions = {}): HomeworldSkillsDiscoveryFixture {
const setup = createHomeworldSkillsDiscoverySetup(options);
let game = structuredClone(setup.afterSetup);
const owner = game.players.find(p => p.faction === 'emperor')!.id;
const fremen = game.players.find(p => p.faction === 'fremen')!.id;
const opponent = game.players.find(p => p.faction === 'guild')?.id ?? null;
const trainer = game.leaderSkills!.assignments.find(a => a.owner === owner)!.leader;
const face = options.face ?? 'cistern';
frontUnplayedSpice(game, card => 'territory' in card && card.discovery === 'discovery-hagga-basin', 0);
if (game.advanced) frontUnplayedSpice(game, card => 'territory' in card && !card.discovery && card.territory !== 'hagga_basin', 1);
const nextTurnIndex = game.advanced ? 2 : 1;
frontUnplayedSpice(game, card => 'worm' in card && !!card.greatMaker, nextTurnIndex);
for (let position = nextTurnIndex + 1; position < nextTurnIndex + (game.advanced ? 3 : 2); position++)
frontUnplayedSpice(game, card => 'territory' in card && !card.discovery && card.territory !== 'hagga_basin', position);
setup.staging.push('Selected only the conserved unplayed Hagga Discovery/next-turn Great Maker Spice order and the original Hiereg supply lottery; every force arrives through an engine action.');
game = advanceHomeworldSkillsDiscovery(game, g => g.phase === 1 && homeworldSkillsDiscoveryClean(g), setup.actions);
game = originalTokenLottery(game, face, () => advanceHomeworldSkillsDiscovery(game,
g => g.discoveries!.tokens.some(t => t.face === face && t.status === 'placed'), setup.actions));
game = advanceHomeworldSkillsDiscovery(game, g => g.phase === 5 && g.active === owner && homeworldSkillsDiscoveryClean(g), setup.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 beforeShipment = structuredClone(game), sources = nativeShipmentSources(viewGame(game, owner), amount, elite); assert.ok(sources);
game = act(game, { actor: owner, action: { type: 'ship', territory: destination.territory,
sector: destination.sector, amount, elite, allyPayment: 0, homeworldSources: sources } }, setup.actions);
game = advanceHomeworldSkillsDiscovery(game, g => !g.pendingShipment && homeworldSkillsDiscoveryClean(g), setup.actions);
const afterShipment = structuredClone(game);
game = advanceHomeworldSkillsDiscovery(game, g => g.phase === 7 && homeworldSkillsDiscoveryClean(g), setup.actions);
const token = game.discoveries!.tokens.find(t => t.face === face)!;
assert.equal(game.turn, 1); assertHomeworldSkillsDiscoveryInventory(game);
return { ...setup, game, owner, fremen, opponent, trainer, token: token.id, face,
parent: placement.territory, parentSector: placement.sector, wormTerritory: blow.territory,
wormSector: blow.sector, amount, elite, beforeShipment, afterShipment };
}
export function revealHomeworldSkillsDiscovery(fixture: HomeworldSkillsDiscoveryFixture): Game {
let game = structuredClone(fixture.game);
if (viewGame(game, fixture.owner).discoveries!.canInspect.includes(fixture.token))
game = applyAction(game, fixture.owner, { type: 'discovery', token: fixture.token, reveal: false });
assert.ok(viewGame(game, fixture.owner).discoveries!.canReveal.includes(fixture.token));
return applyAction(game, fixture.owner, { type: 'discovery', token: fixture.token, reveal: true });
}
export function homeworldSkillsDiscoveryEntryWindow(fixture: HomeworldSkillsDiscoveryFixture): Game {
return advanceHomeworldSkillsDiscovery(revealHomeworldSkillsDiscovery(fixture), game =>
game.turn === 2 && game.decision?.kind === 'discoveryEntry' && game.decision.player === fixture.owner);
}
export function enterHomeworldSkillsDiscovery(fixture: HomeworldSkillsDiscoveryFixture): Game {
const game = homeworldSkillsDiscoveryEntryWindow(fixture), view = viewGame(game, fixture.owner);
const action = discoveryEntryMoveAction(view, view.discoveryEntry!.sources); assert.ok(action);
return applyAction(game, fixture.owner, action);
}
export function homeworldSkillsDiscoveryMakerWindow(fixture: HomeworldSkillsDiscoveryFixture): Game {
return advanceHomeworldSkillsDiscovery(structuredClone(fixture.game), game => game.decision?.kind === 'greatMakerVote');
}
/** Genuine reveal -> next-turn parent entry -> paid reinforcement and enemy
* shipment -> nested battle. Neither physical army is placed by the fixture. */
export function createHomeworldSkillsDiscoveryBattleFixture(options: HomeworldSkillsDiscoveryOptions = {}): HomeworldSkillsDiscoveryBattleFixture {
const fixture = createHomeworldSkillsDiscoveryFixture(options);
assert.ok(fixture.opponent, 'The connected nested battle needs the original Guild seat.');
let game = enterHomeworldSkillsDiscovery(fixture);
const entered = structuredClone(game), reinforcement = game.advanced ? 2 : 3;
const beforeByActor = new Map<string, Game>(), afterByActor = new Map<string, Game>();
// Original storm order determines which real shipment happens first.
game = advanceHomeworldSkillsDiscovery(game, g => g.turn === 2 && g.phase === 5 && homeworldSkillsDiscoveryClean(g), fixture.actions);
const remaining = new Set([fixture.owner, fixture.opponent]);
while (remaining.size) {
game = advanceHomeworldSkillsDiscovery(game, g => g.turn === 2 && g.phase === 5 &&
homeworldSkillsDiscoveryClean(g) && remaining.has(g.active!), fixture.actions);
const actor = game.active!, amount = actor === fixture.owner ? reinforcement : 2;
beforeByActor.set(actor, structuredClone(game));
const sources = nativeShipmentSources(viewGame(game, actor), amount, 0); assert.ok(sources);
game = act(game, { actor, action: { type: 'ship', territory: fixture.face, sector: 0,
amount, elite: 0, allyPayment: 0, homeworldSources: sources } }, fixture.actions);
game = advanceHomeworldSkillsDiscovery(game, g => !g.pendingShipment && homeworldSkillsDiscoveryClean(g), fixture.actions);
afterByActor.set(actor, structuredClone(game)); remaining.delete(actor);
game = act(game, { actor, action: { type: 'endMovement' } }, fixture.actions);
}
game = advanceHomeworldSkillsDiscovery(game, g => g.phase === 6 && homeworldSkillsDiscoveryClean(g), fixture.actions);
const beforeBattle = structuredClone(game), actor = game.active!;
assert.ok(actor === fixture.owner || actor === fixture.opponent);
game = act(game, { actor, action: { type: 'chooseBattle', territory: fixture.face,
target: actor === fixture.owner ? fixture.opponent : fixture.owner } }, fixture.actions);
game = advanceHomeworldSkillsDiscovery(game, g => !!g.battle && homeworldSkillsDiscoveryClean(g) &&
!g.battle.preparation && g.battle.preLeader?.closed !== false, fixture.actions);
const trained = new Set(game.leaderSkills!.assignments.map(a => a.leader));
const enemy = classicHomeworldSkillsPlayer(game, fixture.opponent);
const leader = enemy.leaders.filter(l => !l.dead && !trained.has(l.id)).sort((a, b) => a.strength - b.strength)[0]; assert.ok(leader);
const plans: HomeworldSkillsDiscoveryStep[] = [
{ actor: fixture.owner, action: { type: 'battlePlan', leader: fixture.trainer, dial: 4, support: game.advanced ? 2 : 0 } },
{ actor: fixture.opponent, action: { type: 'battlePlan', leader: leader.id, dial: 0 } },
];
assertHomeworldSkillsDiscoveryInventory(game);
return { ...fixture, game, entered, beforeReinforcement: beforeByActor.get(fixture.owner)!,
afterReinforcement: afterByActor.get(fixture.owner)!, beforeBattle, plans };
}
export function revealHomeworldSkillsDiscoveryBattle(fixture: HomeworldSkillsDiscoveryBattleFixture): Game {
let game = structuredClone(fixture.game);
for (const step of fixture.plans) {
game = applyAction(game, step.actor, step.action);
if (!game.battle?.revealed) game = advanceHomeworldSkillsDiscovery(game,
g => homeworldSkillsDiscoveryClean(g) && !!g.battle && !g.battle.preparation);
}
assert.ok(game.battle?.revealed); return game;
}
export function settleHomeworldSkillsDiscoveryBattle(state: Game, stop: 'suk' | 'complete' = 'complete'): Game {
return advanceHomeworldSkillsDiscovery(state, g => stop === 'suk' && g.decision?.kind === 'sukRescue' ||
!g.battle && homeworldSkillsDiscoveryClean(g));
}
export function homeworldSkillsDiscoveryRescueChoice(game: Game, kept: 'normal' | 'elite' = 'normal'): number {
const decision = game.decision; assert.ok(decision?.kind === 'sukRescue');
const maximum = Math.max(...decision.options.map(o => o.normal + o.elite));
const choice = decision.options.findIndex(o => o.normal + o.elite === maximum && o.kept?.kind === kept);
assert.ok(choice >= 0); return choice;
}
export function homeworldSkillsDiscoverySplitReturn(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], normal = option.normal - (option.kept?.kind === 'normal' ? 1 : 0),
elite = option.elite - (option.kept?.kind === 'elite' ? 1 : 0);
if (!normal && !elite) return undefined;
// Explicit prototype placement interpretation, not printed revival placement:
// return ordinary pieces to Salusa and Sardaukar to Kaitain, without moving a native saved counter.
return Object.fromEntries(decision.reserveHomes.map(home => [home.id,
home.secondary ? { normal, elite: 0 } : { normal: 0, elite }]));
}
@@ -0,0 +1,326 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
applyAction, initializeDiscoveryGameForAudit, viewGame, type Action, type Game,
} from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { ownedTech } from '../game/tech-tokens';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import { forceRevivalQuote } from '../game/revival';
import {
enterHomeworldClassicDiscovery, homeworldClassicDiscoveryInventory,
revealHomeworldClassicDiscovery,
} from './fixture-homeworld-classic-discovery';
import {
advanceHomeworldOptional as advance, claimHomeworldOptional,
createHomeworldOptionalFixture as fixture, createHomeworldOptionalLobby,
finishHomeworldOptionalBattle, initializeHomeworldOptional,
invadeHomeworldOptional, nextHomeworldOptionalStep,
openHomeworldOptionalBattle, optionalHomeworldClean as clean,
optionalHomeworldLeader as leader, optionalHomeworldMovementWindow as movement,
optionalHomeworldPlayer as player, settleHomeworldOptionalArrival, shipHomeworldOptional,
} from './fixture-homeworld-optional-modules';
/** Authorized root rulebook physical pp.21–23: optional modules together or
* separately, original first-Storm token assignment/phase-end collections,
* winner transfer, end-Mentat Stronghold custody, off-planet native reserves and
* combined Imperial shipment. Territory-local effects use the physical original
* Arrakeen/Carthag faces (docs/STRONGHOLD_CARDS.md). All armies, casualties,
* funding, held cards and rewards below originate in original engine actions. */
function reject(game: Game, actor: string, action: Action): void {
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
function legalPolicies(game: Game, actor: string): void {
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, actor); view.players.find(seat => seat.id === actor)!.bot = difficulty;
const actions = botActions(view); assert.ok(actions.length, `${difficulty} has an actual owned continuation.`);
for (const action of actions) homeworldClassicDiscoveryInventory(applyAction(structuredClone(game), actor, action));
}
}
function beforeMovementEnd(state: Game): Game {
let game = state;
for (let i = 0; i < 200; i++) {
assert.equal(game.phase, 5);
const step = nextHomeworldOptionalStep(game);
const after = applyAction(structuredClone(game), step.actor, step.action);
if (after.phase !== 5) return game;
game = after;
}
throw Error('Original Shipping never reached its phase-end action.');
}
function endMovementPhase(game: Game): Game {
const last = nextHomeworldOptionalStep(game), after = applyAction(game, last.actor, last.action);
assert.notEqual(after.phase, 5); return after;
}
function resolveToCleanup(state: Game): Game {
return advance(state, game => game.decision?.kind === 'battleCards');
}
void test('original no-Skills Homeworld Discovery admits Basic/Advanced 2–6 and original optional components only at their real seat/mode boundaries', () => {
for (const advanced of [false, true]) for (const seats of [2, 3, 4, 5, 6] as const) {
const options = { advanced, seats, tech: seats >= 3, strongholds: advanced };
const f = fixture(options);
assert.equal(f.game.players.length, seats);
assert.equal(f.game.phase, 7); assert.equal(f.game.turn, 1);
assert.equal(f.game.leaderSkills, undefined);
homeworldClassicDiscoveryInventory(f.setup); homeworldClassicDiscoveryInventory(f.game);
if (seats >= 3) {
assert.equal(f.beforeFirstStorm.techTokens!.production.owner, f.fremen);
assert.equal(f.beforeFirstStorm.techTokens!.axlotl.owner, null);
assert.equal(f.beforeFirstStorm.techTokens!.heighliners.owner, null);
const assigned = Object.values(f.afterFirstStorm.techTokens!);
assert.ok(assigned.every(token => token.owner));
assert.equal(new Set(assigned.map(token => token.owner)).size, 3);
assert.ok(assigned.every(token => token.spice === 0));
}
if (advanced) {
const claim = claimHomeworldOptional(f.game);
assert.equal(claim.before.strongholdCards!.claimedTurn, 0);
assert.ok(Object.values(claim.before.strongholdCards!.owners).every(owner => owner === null));
assert.equal(claim.after.strongholdCards!.claimedTurn, 1);
if (seats >= 4) assert.equal(claim.after.strongholdCards!.owners.arrakeen, 'atreides');
if (seats >= 5) assert.equal(claim.after.strongholdCards!.owners.carthag, 'harkonnen');
assert.equal(claim.after.strongholdCards!.owners.hidden_mobile_stronghold, null);
homeworldClassicDiscoveryInventory(claim.after);
}
}
assert.throws(() => initializeHomeworldOptional({ seats: 2, tech: true }));
assert.throws(() => initializeHomeworldOptional({ advanced: false, strongholds: true }));
// Invalid ORIGINAL component admission is checked by the actual initializer,
// not by mirroring the profile predicate in an expectation.
const original = createHomeworldOptionalLobby({ tech: true, strongholds: true });
for (const corrupt of [
(game: Game) => { game.techTokens!.axlotl.owner = game.host; },
(game: Game) => { game.techTokens!.heighliners.spice = 1; },
(game: Game) => { game.strongholdCards!.owners.arrakeen = game.host; },
(game: Game) => { game.strongholdCards!.claimedTurn = 1; },
]) {
let changed = structuredClone(original); corrupt(changed);
for (const seat of changed.players) changed = applyAction(changed, seat.id, { type: 'ready' });
assert.throws(() => initializeDiscoveryGameForAudit(changed));
}
});
void test('combined Imperial off-planet shipment and another actual invoice accrue one Heighliner payment, collected once at original Shipping end', () => {
const f = fixture({ tech: true, strongholds: true });
let game = advance(revealHomeworldClassicDiscovery(f), g => g.turn === 2 && g.phase === 5 && clean(g));
const owner = game.techTokens!.heighliners.owner!;
const remaining = new Set([f.collector, 'harkonnen']);
while (remaining.size) {
game = advance(game, g => g.turn === 2 && g.phase === 5 && clean(g) && remaining.has(g.active!));
const actor = game.active!;
if (actor === f.collector) {
const sources = { 'homeworld:emperor': { normal: 1, elite: 0 }, 'homeworld:emperor:salusa': { normal: 1, elite: 0 } };
const ownBefore = structuredClone(player(game, actor));
const homesBefore = structuredClone(viewGame(game, actor).homeworlds!.worlds!);
const target = homesBefore.find(home => home.native === f.fremen)!;
const choice = homeworldShipmentChoice(viewGame(game, actor), target.id, sources); assert.ok(choice.action);
legalPolicies(game, actor);
game = settleHomeworldOptionalArrival(applyAction(game, actor, choice.action));
assert.equal(player(game, actor).spice, ownBefore.spice - 2);
assert.equal(player(game, actor).reserves, ownBefore.reserves - 2);
const homesAfter = viewGame(game, actor).homeworlds!.worlds!;
for (const id of Object.keys(sources)) {
assert.equal(homesAfter.find(home => home.id === id)!.forces[actor].normal,
homesBefore.find(home => home.id === id)!.forces[actor].normal - 1);
}
assert.deepEqual(homesAfter.find(home => home.id === target.id)!.forces[actor], { normal: 2, elite: 0 });
} else {
game = shipHomeworldOptional(game, actor, 'cistern', 0, 1);
}
assert.equal(game.techTokens!.heighliners.spice, 1, 'Two real paid invoices still accrue one industry event.');
assert.equal(game.techTokens!.heighliners.triggeredTurn, 2);
remaining.delete(actor);
if (remaining.size) game = applyAction(game, actor, { type: 'endMovement' });
}
const last = beforeMovementEnd(game), balance = player(last, owner).spice;
const paid = endMovementPhase(last);
assert.equal(player(paid, owner).spice, balance + 1);
assert.equal(paid.techTokens!.heighliners.spice, 0);
assert.equal(paid.techTokens!.axlotl.spice, 0); assert.equal(paid.techTokens!.production.spice, 0);
homeworldClassicDiscoveryInventory(paid);
});
void test('Guild-only actual Homeworld transport is excluded, while a combined Imperial nested shipment pays the printed single tariff and triggers Heighliners', () => {
const f = fixture({ tech: true, strongholds: true });
let guild = movement(f.game, 'guild');
const guildBefore = player(guild, 'guild').spice;
guild = invadeHomeworldOptional(guild, 'guild', 'homeworld:emperor', 1);
assert.equal(player(guild, 'guild').spice, guildBefore - 1);
assert.equal(guild.techTokens!.heighliners.spice, 0);
assert.equal(guild.techTokens!.heighliners.triggeredTurn, 1);
const end = beforeMovementEnd(guild), owner = end.techTokens!.heighliners.owner!, balance = player(end, owner).spice;
const excluded = endMovementPhase(end);
assert.equal(player(excluded, owner).spice, balance);
assert.equal(excluded.techTokens!.heighliners.spice, 0);
let nested = movement(enterHomeworldClassicDiscovery(f), f.collector);
const before = structuredClone(player(nested, f.collector));
nested = shipHomeworldOptional(nested, f.collector, 'cistern', 0, 2, 0,
{ 'homeworld:emperor': { normal: 1, elite: 0 }, 'homeworld:emperor:salusa': { normal: 1, elite: 0 } });
assert.equal(player(nested, f.collector).spice, before.spice - 2);
assert.equal(player(nested, f.collector).forces['cistern:0'], before.forces['cistern:0'] + 2);
assert.equal(nested.techTokens!.heighliners.spice, 1);
homeworldClassicDiscoveryInventory(nested);
});
void test('real low-Caladan poverty claim and three free force returns trigger distinct phase-end industries without charging or recursively paying', () => {
for (const advanced of [false, true]) {
const setup = initializeHomeworldOptional({ advanced, tech: true, strongholds: advanced });
let game = advance(setup, game => game.status === 'playing');
// Select only original unplayed ordinary blows, avoiding incidental income
// at the future expedition site. No phase, wallet or force is assigned.
for (let position = 0; position < 4; position++) {
const index = game.spiceDeck.findIndex((card, i) => i >= position && 'territory' in card &&
!card.discovery && card.territory !== 'imperial_basin');
assert.ok(index >= position); game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
}
game = movement(game, 'emperor', 1);
game = shipHomeworldOptional(game, 'emperor', 'habbanya_ridge_sietch', 17, 1);
game = movement(game, 'atreides', 1);
game = shipHomeworldOptional(game, 'atreides', 'habbanya_ridge_sietch', 17, 9);
game = applyAction(game, 'atreides', { type: 'move', from: 'arrakeen:10', territory: 'imperial_basin', sector: 10, amount: 10 });
assert.equal(player(game, 'atreides').spice, 1);
assert.equal(player(game, 'atreides').reserves, 1);
game = openHomeworldOptionalBattle(game, 'habbanya_ridge_sietch', 'atreides', 'emperor');
game = applyAction(game, 'atreides', { type: 'battlePlan', leader: leader(game, 'atreides', false), dial: 0, support: 0 });
game = applyAction(game, 'emperor', { type: 'battlePlan', leader: leader(game, 'emperor', true), dial: 0, support: 0 });
game = finishHomeworldOptionalBattle(game);
assert.equal(player(game, 'atreides').tanks, 9);
game = advance(game, game => game.turn === 2 && game.phase === 2 && clean(game));
assert.equal(player(game, 'atreides').spice, 1);
assert.equal(viewGame(game, 'atreides').charity.amount, 2);
const productionOwner = game.techTokens!.production.owner!, prodBefore = player(game, productionOwner).spice;
game = applyAction(game, 'atreides', { type: 'charity' });
assert.equal(player(game, 'atreides').spice, 3);
assert.equal(player(game, productionOwner).spice, prodBefore);
assert.equal(game.techTokens!.production.spice, ownedTech(game.techTokens, productionOwner).length);
reject(game, 'atreides', { type: 'charity' });
const beforeCharityEnd = advance(game, game => game.phase === 2 && clean(game) &&
game.ready.length === game.players.length - 1);
const productionIncome = beforeCharityEnd.techTokens!.production.spice;
const productionBalance = player(beforeCharityEnd, productionOwner).spice;
const afterCharity = advance(beforeCharityEnd, game => game.phase === 3 && clean(game));
assert.equal(player(afterCharity, productionOwner).spice, productionBalance + productionIncome);
assert.equal(afterCharity.techTokens!.production.spice, 0);
game = advance(afterCharity, game => game.phase === 4 && clean(game));
const returning = structuredClone(player(game, 'atreides'));
assert.equal(forceRevivalQuote(game, returning, 3).cost, 0, 'Low home adds the third free counter to Atreides printed two.');
legalPolicies(game, 'atreides');
game = applyAction(game, 'atreides', { type: 'revive', amount: 3, elite: 0 });
game = advance(game, game => !game.pendingRevival && clean(game));
assert.equal(player(game, 'atreides').spice, returning.spice);
assert.equal(player(game, 'atreides').tanks, returning.tanks - 3);
assert.equal(player(game, 'atreides').reserves, returning.reserves + 3);
const axlotlOwner = game.techTokens!.axlotl.owner!, accrued = game.techTokens!.axlotl.spice;
assert.equal(accrued, ownedTech(game.techTokens, axlotlOwner).length);
assert.equal(game.techTokens!.production.spice, 0);
const beforeRevivalEnd = advance(game, game => game.phase === 4 && clean(game) && game.ready.length === game.players.length - 1);
const axlotlBalance = player(beforeRevivalEnd, axlotlOwner).spice;
const afterRevival = advance(beforeRevivalEnd, game => game.phase === 5 && clean(game));
assert.equal(player(afterRevival, axlotlOwner).spice, axlotlBalance + accrued);
assert.equal(afterRevival.techTokens!.axlotl.spice, 0);
assert.equal(afterRevival.techTokens!.heighliners.spice, 0);
homeworldClassicDiscoveryInventory(afterRevival);
}
});
void test('actually held Arrakeen subsidy and Carthag Shield apply at their printed Arrakis sites, never at native Homeworld or Cistern battles', () => {
for (const kind of ['support', 'defense'] as const) for (const location of ['printed', 'homeworld', 'nested'] as const) {
const f = fixture({ tech: true, strongholds: true });
const owner = kind === 'support' ? 'atreides' : 'harkonnen';
const printed = kind === 'support' ? 'arrakeen' : 'carthag';
const territory = location === 'printed' ? printed : location === 'homeworld' ? `homeworld:${owner}` : 'cistern';
let game = location === 'nested' ? enterHomeworldClassicDiscovery(f) : f.game;
assert.equal(game.strongholdCards?.owners[printed], location === 'nested' ? owner : null);
if (location === 'nested') {
game = movement(game, owner);
game = shipHomeworldOptional(game, owner, territory, 0, kind === 'support' ? 3 : 2);
} else {
game = movement(game, f.collector);
assert.equal(game.strongholdCards!.owners[printed], owner);
game = location === 'homeworld' ? invadeHomeworldOptional(game, f.collector, territory, 1)
: shipHomeworldOptional(game, f.collector, territory, printed === 'arrakeen' ? 10 : 11, 1);
}
game = openHomeworldOptionalBattle(game, territory, owner, f.collector);
const view = viewGame(game, owner), ownBefore = structuredClone(player(game, owner));
assert.equal(view.battle!.strongholdEffects[owner], location === 'printed' ? printed : null);
const trainer = leader(game, owner, true), invader = leader(game, f.collector, kind === 'defense');
const defense = player(game, owner).hand.find(card => card.kind === 'shield')!; assert.ok(defense);
const weapon = player(game, f.collector).hand.find(card => card.kind === 'poison')!; assert.ok(weapon);
legalPolicies(game, owner);
game = applyAction(game, owner, { type: 'battlePlan', leader: trainer, defense: defense.id,
dial: kind === 'support' ? 2 : 0, support: kind === 'support' ? 2 : 0 });
game = applyAction(game, f.collector, { type: 'battlePlan', leader: invader, dial: 0, support: 0,
...(kind === 'defense' ? { weapon: weapon.id } : {}) });
game = resolveToCleanup(game);
assert.equal(game.lastBattleContext!.territory, territory);
if (kind === 'support') {
assert.equal(player(game, owner).spice, ownBefore.spice - (location === 'printed' ? 0 : 2));
assert.equal(game.lastBattleContext!.winner, owner);
} else {
assert.equal(player(game, owner).leaders.find(disc => disc.id === trainer)!.dead, location !== 'printed');
assert.equal(game.lastBattleContext!.winner, location === 'printed' ? owner : f.collector);
assert.equal(game.discard.some(card => card.id === defense.id), location !== 'printed');
}
assert.equal(game.strongholdCards!.owners[printed], owner, 'Combat never silently transfers physical Stronghold card custody.');
homeworldClassicDiscoveryInventory(game);
}
});
void test('real native Homeworld victory earns a second token, then real visitor victory offers original winner choice only after physical cleanup', () => {
for (const advanced of [false, true]) {
const f = fixture({ advanced, tech: true, strongholds: advanced });
let game = movement(f.game, f.collector);
game = invadeHomeworldOptional(game, f.collector, 'homeworld:fremen', 1);
game = openHomeworldOptionalBattle(game, 'homeworld:fremen', f.collector, f.fremen);
assert.equal(ownedTech(game.techTokens, f.collector).length, 1);
const nativeBefore = player(game, f.fremen).reserves;
game = applyAction(game, f.collector, { type: 'battlePlan', leader: leader(game, f.collector, false), dial: 0, support: 0 });
game = applyAction(game, f.fremen, { type: 'battlePlan', leader: leader(game, f.fremen, true), dial: 0, support: 0 });
game = finishHomeworldOptionalBattle(game);
assert.equal(game.lastBattleContext!.winner, f.fremen);
assert.equal(player(game, f.fremen).reserves, nativeBefore);
assert.equal(player(game, f.collector).tanks, 1);
assert.equal(ownedTech(game.techTokens, f.fremen).length, 2);
game = movement(game, f.collector, 3);
game = invadeHomeworldOptional(game, f.collector, 'homeworld:fremen', 2);
game = openHomeworldOptionalBattle(game, 'homeworld:fremen', f.collector, f.fremen);
const tokens = ownedTech(game.techTokens, f.fremen), originalTech = structuredClone(game.techTokens);
const visitorBefore = structuredClone(player(game, f.collector));
const nativeBeforeSecond = structuredClone(player(game, f.fremen));
const weapon = visitorBefore.hand.find(card => card.kind === 'poison')!; assert.ok(weapon);
game = applyAction(game, f.collector, { type: 'battlePlan', leader: leader(game, f.collector, true), dial: 1,
support: advanced ? 1 : 0, weapon: weapon.id });
game = applyAction(game, f.fremen, { type: 'battlePlan', leader: leader(game, f.fremen, false), dial: 0, support: 0 });
const cleanup = resolveToCleanup(game);
assert.equal(cleanup.lastBattleContext!.winner, f.collector);
assert.deepEqual(cleanup.techTokens, originalTech);
assert.equal(cleanup.decision!.player, f.collector);
assert.equal(player(cleanup, f.collector).tanks, visitorBefore.tanks + 1);
assert.equal(player(cleanup, f.fremen).tanks, nativeBeforeSecond.tanks + nativeBeforeSecond.reserves);
game = applyAction(cleanup, f.collector, { type: 'decision', discard: [] });
game = advance(game, game => game.decision?.kind === 'techToken');
assert.ok(game.decision?.kind === 'techToken');
assert.equal(game.decision.player, f.collector);
assert.deepEqual([...game.decision.choices].sort(), [...tokens].sort());
assert.deepEqual(game.techTokens, originalTech);
assert.equal(player(game, f.collector).hand.some(card => card.id === weapon.id), true);
const physicalHome = viewGame(game, f.collector).homeworlds!.worlds!.find(home => home.native === f.fremen)!;
assert.deepEqual(physicalHome.forces[f.collector], { normal: 1, elite: 0 });
assert.deepEqual(physicalHome.forces[f.fremen], { normal: 0, elite: 0 });
legalPolicies(game, f.collector);
for (const token of tokens) {
reject(game, f.fremen, { type: 'decision', token });
const chosen = applyAction(structuredClone(game), f.collector, { type: 'decision', token });
assert.equal(chosen.techTokens![token].owner, f.collector);
assert.equal(ownedTech(chosen.techTokens, f.fremen).length, 1);
assert.equal(chosen.lastBattleContext!.winner, f.collector);
const chosenHome = viewGame(chosen, f.collector).homeworlds!.worlds!.find(home => home.id === physicalHome.id)!;
assert.deepEqual(chosenHome.forces, physicalHome.forces, 'Tech selection changes token custody, never the actual winner army.');
homeworldClassicDiscoveryInventory(chosen);
}
}
});
@@ -0,0 +1,247 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { DIFFICULTIES } from '../game/bot-profiles';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { gameTerritories, validGameLocation } from '../game/board';
import { discoveryEntryDeclineAction, discoveryEntryMoveAction } from '../game/discovery-entry-options';
import { greatMakerRideAction } from '../game/great-maker-options';
import { homeworldContext } from '../game/homeworld-game';
import { homeworldForceGroups } from '../game/homeworld-custody';
import type { HomeworldCombatLossContext } from '../game/homeworld-combat-loss';
import { quoteSukPhysicalRescue } from '../game/suk-graduate';
import {
classicHomeworldSkillsBotOffers, createClassicHomeworldSkillsFixture,
quoteClassicHomeworldSkillsBattle, revealClassicHomeworldSkillsBattle,
settleClassicHomeworldSkillsBattle,
} from './fixture-homeworld-classic-skills';
import {
advanceHomeworldSkillsDiscovery as advance, assertHomeworldSkillsDiscoveryInventory as inventory,
createHomeworldSkillsDiscoveryBattleFixture as battleFixture, createHomeworldSkillsDiscoveryFixture as fixture,
createHomeworldSkillsDiscoveryLobby as lobby, createHomeworldSkillsDiscoverySetup as setup,
homeworldSkillsDiscoveryClean as clean, homeworldSkillsDiscoveryEntryWindow as entryWindow,
homeworldSkillsDiscoveryMakerWindow as makerWindow, homeworldSkillsDiscoveryPlayer as player,
homeworldSkillsDiscoveryRescueChoice as rescueChoice, homeworldSkillsDiscoverySplitReturn as splitReturn,
revealHomeworldSkillsDiscovery as reveal, revealHomeworldSkillsDiscoveryBattle as revealBattle,
settleHomeworldSkillsDiscoveryBattle as settle,
} from './fixture-homeworld-skills-discovery';
/** Authorized root revision physical PDF21–23: original variants may compose.
* Programmes select original undealt offers and unplayed Spice/token lotteries;
* actions alone establish native withdrawals, casualties, reveal, entry and wins.
* External Emperor split return remains the documented prototype placement
* interpretation, not the PDF22 revival rule or a publisher allocation ruling. */
function legalPolicies(game: Game, actor: string): void {
for (const difficulty of DIFFICULTIES) {
const offers = classicHomeworldSkillsBotOffers(game, actor, difficulty);
assert.ok(offers.length, `${difficulty} needs an owned original continuation.`);
inventory(applyAction(structuredClone(game), actor, offers[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 physicalContext(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 } };
}) };
}
void test('original combined Basic/Advanced two-through-six setup deals all14 before faction choices and preserves DS7/8 and original hands', () => {
for (const advanced of [false, true]) for (const seats of [2, 3, 4, 5, 6] as const) {
const f = setup({ advanced, seats }), physical = f.offered.leaderSkills!;
assert.equal(f.afterSetup.players.length, seats);
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());
assert.equal(f.offered.setupStage, 'leaderSkills');
assert.equal(f.offered.players.flatMap(p => p.traitors).length, 0);
assert.equal(f.afterSetup.leaderSkills!.assignments.length, seats);
for (const p of f.afterSetup.players) {
assert.equal(p.hand.length, p.faction === 'harkonnen' ? 2 : 1);
assert.ok(f.afterSetup.leaderSkills!.assignments.some(a => a.owner === p.id && p.leaders.some(l => l.id === a.leader && !l.dead)));
}
for (const p of f.offered.players) legalPolicies(f.offered, p.id);
inventory(f.offered); inventory(f.afterSetup);
}
});
void test('all fourteen original skills remain physically selectable through combined Emperor offers in both rules modes', () => {
for (const advanced of [false, true]) for (const card of LEADER_SKILL_CARDS) {
const f = setup({ advanced, seats: 2, skill: card.id });
assert.equal(f.afterSetup.leaderSkills!.assignments.find(a => a.owner === 'emperor')!.skill, card.id);
legalPolicies(f.offered, 'emperor'); inventory(f.afterSetup);
}
});
void test('real Maker losses precede actual storm-order majority or tie votes and a free typed native-reserve ride without losing training', () => {
for (const advanced of [false, true]) for (const majority of [false, true]) {
const f = fixture({ advanced, shipForMaker: true });
const beforeArmy = player(f.game, f.owner), key = `${f.wormTerritory}:${f.wormSector}`;
assert.equal(beforeArmy.forces[key], f.amount);
assert.equal(beforeArmy.elites?.forces[key] ?? 0, f.elite);
const originalTraining = structuredClone(f.game.leaderSkills!.assignments);
const originalHomes = structuredClone(f.game.homeworlds!.custody);
let game = makerWindow(f);
const devoured = player(game, f.owner);
assert.equal(game.turn, 2); assert.equal(game.phase, 1);
assert.equal(game.greatMaker!.territory, f.wormTerritory);
assert.equal(devoured.forces[key] ?? 0, 0);
assert.equal(devoured.tanks, beforeArmy.tanks + f.amount);
assert.equal(devoured.elites?.tanks ?? 0, (beforeArmy.elites?.tanks ?? 0) + f.elite);
assert.equal(devoured.reserves, beforeArmy.reserves);
assert.deepEqual(game.homeworlds!.custody, originalHomes);
assert.deepEqual(game.leaderSkills!.assignments, originalTraining);
assert.equal(game.discoveries!.tokens.find(t => t.id === f.token)!.revealedTurn, null);
const order = [...game.order];
for (const [index, actor] of order.entries()) {
assert.ok(game.decision?.kind === 'greatMakerVote'); assert.equal(game.decision.player, actor);
legalPolicies(game, actor);
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, actor, vote);
}
assert.equal(game.nexus, majority, 'Three of four opens Nexus; two of four does not.');
assert.ok(game.decision?.kind === 'greatMakerRide'); assert.equal(game.decision.player, f.fremen);
legalPolicies(game, f.fremen);
const view = viewGame(game, f.fremen), target = view.greatMaker!.ride!.destinations.find(d => d.territory === 'polar_sink'); assert.ok(target);
const before = structuredClone(player(game, f.fremen)), training = structuredClone(game.leaderSkills!.assignments);
const elite = advanced ? 1 : 0, action = greatMakerRideAction(view, target.territory, target.sector, 2, elite); assert.ok(action);
reject(game, f.fremen, { ...action, event: 'stale-maker-ride' });
reject(game, f.fremen, { ...action, territory: 'homeworld:fremen', sector: 0 });
reject(game, f.fremen, { ...action, elite: 4 });
game = applyAction(game, f.fremen, action);
const after = player(game, f.fremen), destination = `${target.territory}:${target.sector}`;
assert.equal(after.reserves, before.reserves - 2);
assert.equal(after.elites?.reserves ?? 0, (before.elites?.reserves ?? 0) - elite);
assert.equal(after.forces[destination], (before.forces[destination] ?? 0) + 2);
assert.equal(after.elites?.forces[destination] ?? 0, (before.elites?.forces[destination] ?? 0) + elite);
assert.equal(after.spice, before.spice); assert.equal(after.moved, before.moved); assert.equal(after.shipped, before.shipped);
const native = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!).find(h => h.native === f.fremen)!;
assert.deepEqual(native.forces[f.fremen], { normal: after.reserves - (after.elites?.reserves ?? 0), elite: after.elites?.reserves ?? 0 });
assert.deepEqual(game.leaderSkills!.assignments, training); inventory(game);
}
});
void test('actual inspection/reveal and later entry move only real parent forces, retaining native custody, training and normal movement allowances', () => {
for (const advanced of [false, true]) {
const f = fixture({ advanced }), revealed = reveal(f);
assert.equal(validGameLocation(f.game, f.face, 0), false);
assert.equal(validGameLocation(revealed, f.face, 0), true);
assert.equal(revealed.discoveries!.tokens.find(t => t.id === f.token)!.revealedTurn, 1);
assert.equal(player(revealed, f.owner).forces[`${f.face}:0`] ?? 0, 0, 'Reveal itself is not physical entry.');
const game = entryWindow(f), view = viewGame(game, f.owner), sources = view.discoveryEntry!.sources;
assert.equal(game.turn, 2); assert.equal(game.phase, 0);
assert.deepEqual(sources, [{ source: `${f.parent}:${f.parentSector}`, normal: f.amount - f.elite, elite: f.elite }]);
legalPolicies(game, f.owner);
const action = discoveryEntryMoveAction(view, sources), decline = discoveryEntryDeclineAction(view); assert.ok(action && decline);
const before = structuredClone(player(game, f.owner)), custody = structuredClone(game.homeworlds!.custody), training = structuredClone(game.leaderSkills!.assignments);
assert.deepEqual(player(applyAction(structuredClone(game), f.owner, decline), f.owner).forces, before.forces);
reject(game, f.owner, { ...action, event: 'stale-discovery-entry' });
reject(game, f.fremen, action);
for (const source of ['homeworld:emperor', 'homeworld:emperor:salusa', `${f.face}:0`]) {
const groups = [{ source, normal: 1, elite: 0 }];
assert.equal(discoveryEntryMoveAction(view, groups), null); reject(game, f.owner, { ...action, groups });
}
const entered = applyAction(JSON.parse(JSON.stringify(game)), f.owner, action), after = player(entered, f.owner);
assert.equal(after.forces[`${f.face}:0`], f.amount); assert.equal(after.elites?.forces[`${f.face}:0`] ?? 0, f.elite);
assert.equal(after.forces[`${f.parent}:${f.parentSector}`] ?? 0, 0);
assert.deepEqual(entered.homeworlds!.custody, custody); assert.deepEqual(entered.leaderSkills!.assignments, training);
assert.equal(after.reserves, before.reserves); assert.equal(after.elites?.reserves, before.elites?.reserves);
assert.equal(after.spice, before.spice); assert.equal(after.moved, before.moved); assert.equal(after.shipped, before.shipped);
assert.ok(gameTerritories(entered).every(t => !t.id.startsWith('homeworld:'))); inventory(entered);
}
});
for (const advanced of [false, true]) {
void test(`${advanced ? 'Advanced' : 'Basic'} actual trained Suk nested win has no native bonus and returns only typed rescued pieces to real homes`, () => {
const f = battleFixture({ advanced });
const owner = f.owner, opponent = f.opponent!;
const reinforcedBefore = player(f.beforeReinforcement, owner), reinforcedAfter = player(f.afterReinforcement, owner), reinforcement = advanced ? 2 : 3;
assert.equal(reinforcedAfter.spice, reinforcedBefore.spice - reinforcement, 'One spice per real nested reinforcement, not a Homeworld invasion price.');
assert.equal(reinforcedAfter.reserves, reinforcedBefore.reserves - reinforcement);
assert.equal(reinforcedAfter.forces[`${f.face}:0`], 5);
assert.equal(player(f.game, opponent).forces[`${f.face}:0`], 2);
for (const actor of [owner, opponent]) legalPolicies(f.game, actor);
const revealed = revealBattle(f), quote = quoteClassicHomeworldSkillsBattle(revealed), own = revealed.battle!.attacker === owner ? 'attacker' : 'defender';
assert.equal(quote.winner, owner);
assert.equal(quote.scores![own], 4 + player(revealed, owner).leaders.find(l => l.id === f.trainer)!.strength);
assert.equal(quote.leaderSkillBonuses[own].bonus, 0, 'Suk rescue is not native Homeworld strength or a battle-strength skill.');
assert.deepEqual(viewGame(revealed, owner).battle!.traitorVoters.sort(), [owner, opponent].sort(), 'Nested Arrakis battle retains both ordinary traitor-call owners.');
const pending = settle(revealed, 'suk'); assert.ok(pending.decision?.kind === 'sukRescue');
const receipt = pending.pendingSukRescue!, choice = rescueChoice(pending), option = pending.decision.options[choice], destinations = splitReturn(pending, choice);
assert.equal(receipt.territory, f.face); assert.equal(receipt.skill.mode, 'skilled');
assert.deepEqual(receipt.pool, [{ key: `${f.face}:0`, normal: advanced ? 4 : 5, elite: advanced ? 1 : 0 }]);
assert.equal(receipt.losses!.normal, advanced ? 2 : 4); assert.equal(receipt.losses!.elite, advanced ? 1 : 0);
assert.equal(option.normal + option.elite, 3); assert.deepEqual(option.kept, { key: `${f.face}:0`, kind: 'normal' });
legalPolicies(pending, owner);
const action: Action = { type: 'decision', event: pending.decision.event, choice, ...(destinations ? { destinations } : {}) };
reject(pending, owner, { ...action, event: 'stale-suk-rescue' });
reject(pending, opponent, action);
reject(pending, owner, { ...action, destinations: { [`${f.face}:0`]: { normal: 2, elite: 0 } } });
reject(pending, owner, { ...action, destinations: { 'homeworld:guild': { normal: 2, elite: 0 } } });
if (advanced) {
assert.equal(pending.decision.reserveHomes?.length, 2);
assert.deepEqual(destinations, { 'homeworld:emperor': { normal: 0, elite: 1 }, 'homeworld:emperor:salusa': { normal: 1, elite: 0 } });
reject(pending, owner, { type: 'decision', event: pending.decision.event, choice });
reject(pending, owner, { ...action, destinations: { 'homeworld:emperor': { normal: 1, elite: 0 } } });
reject(pending, owner, { ...action, destinations: { 'homeworld:emperor': { normal: 0, elite: 2 }, 'homeworld:emperor:salusa': { normal: 0, elite: 0 } } });
}
const physical = quoteSukPhysicalRescue(physicalContext(pending), pending.homeworlds!.custody!, {
player: owner, territory: f.face, skill: receipt.skill, pool: receipt.pool, losses: receipt.losses!, option, destinations,
});
assert.equal(physical.receipt.source, 'arrakis');
const before = structuredClone(player(pending, owner)), homeBefore = homeworldForceGroups(homeworldContext(pending), pending.homeworlds!.custody!);
const done = settle(applyAction(JSON.parse(JSON.stringify(pending)), owner, action)), after = player(done, owner);
assert.equal(done.lastBattleContext!.winner, owner);
assert.equal(after.forces[`${f.face}:0`], advanced ? 3 : 2, 'Only undialed pieces plus the one physically kept ordinary counter remain.');
assert.equal(after.elites?.forces[`${f.face}:0`] ?? 0, 0);
assert.equal(after.reserves, before.reserves + 2); assert.equal(after.elites?.reserves ?? 0, (before.elites?.reserves ?? 0) + (advanced ? 1 : 0));
assert.equal(after.tanks, before.tanks + (advanced ? 0 : 1));
assert.equal(before.spice, reinforcedAfter.spice - (advanced ? 2 : 0), 'Actual support is paid before rescue and independent Collection.');
assert.equal(player(done, opponent).forces[`${f.face}:0`] ?? 0, 0);
assert.equal(player(done, opponent).tanks, player(f.game, opponent).tanks + 2);
assert.deepEqual(done.homeworlds!.custody, physical.custody);
const homeAfter = homeworldForceGroups(homeworldContext(done), done.homeworlds!.custody!);
if (advanced) for (const home of homeBefore.filter(h => h.native === owner)) {
const afterHome = homeAfter.find(h => h.id === home.id)!, returned = destinations![home.id];
assert.equal(afterHome.forces[owner].normal, home.forces[owner].normal + returned.normal);
assert.equal(afterHome.forces[owner].elite, home.forces[owner].elite + returned.elite);
}
assert.equal(player(done, owner).leaders.find(l => l.id === f.trainer)!.dead, false);
assert.ok(done.leaderSkills!.assignments.some(a => a.owner === owner && a.leader === f.trainer && a.skill === 'suk-graduate'));
assert.equal(done.pendingSukRescue ?? null, null); inventory(done);
const later = advance(done, g => g.phase === 7 && clean(g));
assert.equal(player(later, owner).spice, before.spice + 2, 'The surviving nested army receives printed Cistern income, not a support refund.');
assert.equal(player(later, owner).forces[`${f.face}:0`], after.forces[`${f.face}:0`]); inventory(later);
});
void test(`${advanced ? 'Advanced' : 'Basic'} original native and visitor Homeworld Suk rescues still use their proper physical pools with Discovery retained`, () => {
for (const kind of ['native-suk', 'visitor-suk'] as const) {
const initial = lobby({ advanced, roster: ['emperor', 'guild', 'atreides', 'harkonnen'] });
const f = createClassicHomeworldSkillsFixture({ initial, advanced, kind }), pending = settleClassicHomeworldSkillsBattle(revealClassicHomeworldSkillsBattle(f), 'suk');
assert.ok(pending.decision?.kind === 'sukRescue');
const choice = rescueChoice(pending, kind === 'native-suk' && advanced ? 'elite' : 'normal'), option = pending.decision.options[choice], receipt = pending.pendingSukRescue!;
const destinations = splitReturn(pending, choice);
if (kind === 'native-suk') {
assert.equal(pending.decision.reserveHomes, undefined);
reject(pending, f.owner, { type: 'decision', event: pending.decision.event, choice, destinations: { 'homeworld:emperor': { normal: 1, elite: 0 } } });
}
const physical = quoteSukPhysicalRescue(physicalContext(pending), pending.homeworlds!.custody!, {
player: f.owner, territory: f.territory, skill: receipt.skill, pool: receipt.pool, losses: receipt.losses!, option, destinations,
});
assert.equal(physical.receipt.source, kind === 'native-suk' ? 'native-homeworld' : 'visitor-homeworld');
legalPolicies(pending, f.owner);
const done = settleClassicHomeworldSkillsBattle(applyAction(pending, f.owner,
{ type: 'decision', event: pending.decision.event, choice, ...(destinations ? { destinations } : {}) }));
assert.deepEqual(done.homeworlds!.custody, physical.custody);
if (kind === 'native-suk' && advanced) assert.deepEqual(done.homeworlds!.custody!.salusa, pending.homeworlds!.custody!.salusa, 'Saved native Sardaukar never leave their original Salusa pool.');
assert.ok(done.leaderSkills!.assignments.some(a => a.leader === f.trainer && a.skill === 'suk-graduate'));
inventory(done);
}
});
}
+32 -19
View File
@@ -43,6 +43,13 @@ import { privateOutputDirectory, sourceSnapshot } from './verification';
const DEFAULT_SEED = 20_260_926;
const DEFAULT_MAX_ACTIONS = 3_500;
const DIFFICULTIES = ['Easy', 'Medium', 'Hard', 'Brutal'] as const;
const HOMEWORLD_CLASSIC_MODULE_PROFILES = [
'homeworld-skills', 'homeworld-discovery',
'homeworld-skills-tech', 'homeworld-skills-stronghold', 'homeworld-skills-stronghold-tech',
'homeworld-discovery-tech', 'homeworld-discovery-stronghold', 'homeworld-discovery-stronghold-tech',
'homeworld-discovery-skills', 'homeworld-discovery-skills-tech',
'homeworld-discovery-skills-stronghold', 'homeworld-discovery-skills-stronghold-tech',
] as const;
type Profile = 'base' | 'choam' | 'ecaz' | 'ecaz-treachery' | 'ecaz-occupy' | 'moritani-assassinate' | 'stronghold-factions' | 'combined' | 'combined-nexus' | 'combined-homeworld-nexus' | 'homeworld' | 'nexus' | 'homeworld-nexus' | 'choam-roster' | 'ecaz-roster' | 'ix-roster' | 'choam-nexus' | 'ecaz-nexus' | 'ix-nexus' | 'moritani-skills' | 'tleilaxu-skills' | 'ix-skills' | 'choam-skills' | 'richese-skills' | 'ecaz-skills' | 'skills-tech' | 'skills-stronghold' | 'skills-stronghold-tech'
| 'factions-tech' | 'stronghold-factions-tech' | 'nexus-tech' | 'nexus-stronghold' | 'nexus-stronghold-tech'
| 'paired-nexus-tech' | 'paired-nexus-stronghold' | 'paired-nexus-stronghold-tech' | 'nexus-skills' | 'paired-nexus-skills'
@@ -57,7 +64,7 @@ type Profile = 'base' | 'choam' | 'ecaz' | 'ecaz-treachery' | 'ecaz-occupy' | 'm
| 'paired-discovery-nexus' | 'paired-discovery-nexus-tech' | 'paired-discovery-nexus-stronghold' | 'paired-discovery-nexus-stronghold-tech'
| 'paired-discovery-nexus-skills' | 'paired-discovery-nexus-skills-tech' | 'paired-discovery-nexus-skills-stronghold' | 'paired-discovery-nexus-skills-stronghold-tech'
| 'e3-discovery-nexus-skills' | 'e3-discovery-nexus-skills-tech' | 'e3-discovery-nexus-skills-stronghold' | 'e3-discovery-nexus-skills-stronghold-tech'
| 'homeworld-skills' | 'homeworld-discovery';
| (typeof HOMEWORLD_CLASSIC_MODULE_PROFILES)[number];
const discoveryStrongholdProfile = (profile: string) => profile === 'discovery-stronghold' ||
profile === 'discovery-stronghold-tech' || profile === 'discovery-skills-stronghold' ||
profile === 'discovery-skills-stronghold-tech' || profile === 'native-discovery-skills-stronghold' ||
@@ -80,7 +87,16 @@ const nativeDiscoveryNexusSkillsProfile = (profile: string) =>
profile === 'paired-discovery-nexus-skills-stronghold' || profile === 'paired-discovery-nexus-skills-stronghold-tech' ||
profile === 'e3-discovery-nexus-skills' || profile === 'e3-discovery-nexus-skills-tech' ||
profile === 'e3-discovery-nexus-skills-stronghold' || profile === 'e3-discovery-nexus-skills-stronghold-tech';
const homeworldClassicModuleProfile = (profile: string) => profile === 'homeworld-skills' || profile === 'homeworld-discovery';
const homeworldClassicModuleProfile = (profile: string) =>
(HOMEWORLD_CLASSIC_MODULE_PROFILES as readonly string[]).includes(profile);
const homeworldClassicSkillsProfile = (profile: string) =>
homeworldClassicModuleProfile(profile) && profile.includes('-skills');
const homeworldClassicDiscoveryProfile = (profile: string) =>
homeworldClassicModuleProfile(profile) && profile.includes('-discovery');
const homeworldClassicTechProfile = (profile: string) =>
homeworldClassicModuleProfile(profile) && profile.endsWith('-tech');
const homeworldClassicStrongholdProfile = (profile: string) =>
homeworldClassicModuleProfile(profile) && profile.includes('-stronghold');
type Rules = 'basic' | 'advanced';
type Scenario = {
@@ -156,8 +172,10 @@ const BASE_SCENARIOS: readonly Scenario[] = [2, 3, 4, 5, 6].flatMap((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 })));
HOMEWORLD_CLASSIC_MODULE_PROFILES.flatMap((profile, band) =>
BASE_SCENARIOS.map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 6400 + band * 100 + index }))
.filter(scenario => (!homeworldClassicTechProfile(profile) || scenario.roster.length >= 3) &&
(!homeworldClassicStrongholdProfile(profile) || scenario.rules === 'advanced')));
const NEXUS_SKILLS_SCENARIOS: readonly Scenario[] = BASE_SCENARIOS.map((scenario, index) => ({
...scenario, profile: 'nexus-skills', ordinal: 1410 + index,
}));
@@ -565,9 +583,10 @@ const SKILLS_STRONGHOLD_SCENARIOS: readonly Scenario[] =
const strongholdSkillProfile = (profile: string) => profile === 'skills-stronghold' || profile === 'skills-stronghold-tech';
const discoverySkillsProfile = (profile: string) => profile === 'discovery-skills' || profile === 'discovery-skills-tech' ||
profile === 'discovery-skills-stronghold' || profile === 'discovery-skills-stronghold-tech';
const skillProfile = (profile: string) => 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 skillProfile = (profile: string) => homeworldClassicSkillsProfile(profile) || nativeDiscoveryNexusSkillsProfile(profile) || discoveryNexusSkillsProfile(profile) || nativeDiscoverySkillsProfile(profile) || discoverySkillsProfile(profile) || e3NexusSkillsProfile(profile) || pairedNexusSkillsModuleProfile(profile) || nexusSkillsModuleProfile(profile) || profile === 'nexus-skills' || profile === 'paired-nexus-skills' || profile === 'moritani-skills' || profile === 'tleilaxu-skills' || profile === 'ix-skills' || profile === 'choam-skills' || profile === 'richese-skills' || profile === 'ecaz-skills' || profile === 'skills-tech' || strongholdSkillProfile(profile);
const discoveryProfile = (profile: string) => profile === 'discovery' || profile === 'discovery-tech' ||
profile === 'discovery-stronghold' || profile === 'discovery-stronghold-tech' || profile === 'homeworld-discovery';
profile === 'discovery-stronghold' || profile === 'discovery-stronghold-tech' ||
(homeworldClassicDiscoveryProfile(profile) && !homeworldClassicSkillsProfile(profile));
const assassinationScenario = (scenario: Scenario) => scenario.rules === 'advanced' &&
scenario.roster.includes('moritani') &&
(scenario.profile === 'moritani-assassinate' || discoveryProfile(scenario.profile) || nativeStrongholdProfile(scenario.profile) || nativeTechProfile(scenario.profile) || skillProfile(scenario.profile));
@@ -635,7 +654,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.'
+ '\nHomeworld-skills, homeworld-discovery and homeworld-discovery-skills use original classic/base33 rosters in Basic/Advanced2..6, all14 offered training when Skills is selected and Discovery7/8 when Discovery is selected. Each family may add -tech (original Tech3+), -stronghold (Advanced2+) or -stronghold-tech (Advanced3+). Original setup/cards/native force sources, physical rescue, Maker/entry, phase-end industry and source-local held effects remain. No Nexus, native families, public starts, save conversion or pending-rule changes.'
);
}
@@ -760,13 +779,13 @@ function freshGame(scenario: Scenario) {
player.ready = true;
}
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 (homeworldClassicTechProfile(scenario.profile) || discoveryTechProfile(scenario.profile) || scenario.profile === 'skills-tech' || scenario.profile === 'skills-stronghold-tech' || nativeTechProfile(scenario.profile) || nexusTechProfile(scenario.profile)) game.techTokens = createTechTokens();
if (homeworldClassicStrongholdProfile(scenario.profile) || discoveryStrongholdProfile(scenario.profile) || strongholdSkillProfile(scenario.profile) || nexusStrongholdProfile(scenario.profile)) game.strongholdCards = createStrongholdCards();
if (homeworldClassicModuleProfile(scenario.profile) || scenario.profile === 'homeworld' || scenario.profile === 'homeworld-occupation' || scenario.profile === 'homeworld-nexus' || scenario.profile === 'combined-homeworld-nexus')
game.homeworlds = { custody: null };
if (scenario.profile === 'paired-nexus-skills' || scenario.profile === 'nexus-skills' || scenario.profile === 'nexus' || nexusModuleProfile(scenario.profile) || scenario.profile === 'homeworld-nexus' || scenario.profile === 'choam-nexus' || scenario.profile === 'ecaz-nexus' || scenario.profile === 'ix-nexus' || scenario.profile === 'combined-nexus' || scenario.profile === 'combined-homeworld-nexus')
game.nexusCards = { cards: null, phase: null };
if (nativeDiscoverySkillsProfile(scenario.profile) || discoverySkillsProfile(scenario.profile) || discoveryNexusProfile(scenario.profile)) game.discoveryEnabled = true;
if (homeworldClassicDiscoveryProfile(scenario.profile) || nativeDiscoverySkillsProfile(scenario.profile) || discoverySkillsProfile(scenario.profile) || discoveryNexusProfile(scenario.profile)) game.discoveryEnabled = true;
if (discoveryProfile(scenario.profile)) {
game.discoveryEnabled = true;
return initializeDiscoveryGameForAudit(game);
@@ -838,13 +857,13 @@ function resumedGame(path: string) {
throw new Error('--resume sample scenarios exclude unsupported optional modules.');
const scenario = [...SCENARIOS, ...COMBINED_NEXUS_SCENARIOS, ...COMBINED_HOMEWORLD_NEXUS_SCENARIOS, ...BASE_SCENARIOS, ...MODULE_SCENARIOS, ...EXPANSION_ROSTER_SCENARIOS, ...ECAZ_TREACHERY_SCENARIOS, ...ECAZ_OCCUPY_SCENARIOS, ...PAIRED_NEXUS_SCENARIOS, ...MORITANI_ASSASSINATE_SCENARIOS, ...STRONGHOLD_FACTIONS_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...IX_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS, ...RICHESE_SKILLS_SCENARIOS, ...ECAZ_SKILLS_SCENARIOS, ...SKILLS_TECH_SCENARIOS, ...SKILLS_STRONGHOLD_SCENARIOS, ...NATIVE_TECH_SCENARIOS, ...CLASSIC_NEXUS_MODULE_SCENARIOS, ...PAIRED_NEXUS_MODULE_SCENARIOS, ...NEXUS_SKILLS_SCENARIOS, ...PAIRED_NEXUS_SKILLS_SCENARIOS, ...NEXUS_SKILLS_MODULE_SCENARIOS, ...PAIRED_NEXUS_SKILLS_MODULE_SCENARIOS, ...E3_NEXUS_SKILLS_SCENARIOS, ...HOMEWORLD_OCCUPATION_SCENARIOS, ...DISCOVERY_SCENARIOS, ...DISCOVERY_SKILLS_SCENARIOS, ...DISCOVERY_NEXUS_SCENARIOS, ...DISCOVERY_STRONGHOLD_SCENARIOS, ...NATIVE_DISCOVERY_SKILLS_SCENARIOS, ...DISCOVERY_NEXUS_SKILLS_SCENARIOS, ...PAIRED_DISCOVERY_NEXUS_SCENARIOS, ...NATIVE_DISCOVERY_NEXUS_SKILLS_SCENARIOS, ...HOMEWORLD_CLASSIC_MODULE_SCENARIOS].find(
(candidate) =>
(nativeDiscoverySkillsProfile(candidate.profile) || discoveryProfile(candidate.profile) || discoverySkillsProfile(candidate.profile) || discoveryNexusProfile(candidate.profile)) === discovery &&
(homeworldClassicDiscoveryProfile(candidate.profile) || nativeDiscoverySkillsProfile(candidate.profile) || discoveryProfile(candidate.profile) || discoverySkillsProfile(candidate.profile) || discoveryNexusProfile(candidate.profile)) === discovery &&
(homeworldClassicModuleProfile(candidate.profile) || candidate.profile === 'homeworld' || candidate.profile === 'homeworld-occupation' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.homeworlds &&
(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 &&
(discoveryTechProfile(candidate.profile) || candidate.profile === 'skills-tech' || candidate.profile === 'skills-stronghold-tech' || nativeTechProfile(candidate.profile) || nexusTechProfile(candidate.profile)) === !!game.techTokens &&
(discoveryStrongholdProfile(candidate.profile) || nativeStrongholdProfile(candidate.profile) || strongholdSkillProfile(candidate.profile) || nexusStrongholdProfile(candidate.profile)) === !!game.strongholdCards &&
(homeworldClassicTechProfile(candidate.profile) || discoveryTechProfile(candidate.profile) || candidate.profile === 'skills-tech' || candidate.profile === 'skills-stronghold-tech' || nativeTechProfile(candidate.profile) || nexusTechProfile(candidate.profile)) === !!game.techTokens &&
(homeworldClassicStrongholdProfile(candidate.profile) || discoveryStrongholdProfile(candidate.profile) || nativeStrongholdProfile(candidate.profile) || strongholdSkillProfile(candidate.profile) || nexusStrongholdProfile(candidate.profile)) === !!game.strongholdCards &&
(candidate.profile === 'ecaz-treachery' || candidate.ecazTreachery === true) === !!game.ecazTreachery &&
assassinationScenario(candidate) === !!game.moritaniAssassinatePreview &&
(candidate.profile === 'ecaz-occupy' || ((nativeDiscoveryNexusSkillsProfile(candidate.profile) || nativeDiscoverySkillsProfile(candidate.profile) || nativeTechProfile(candidate.profile) || discoveryProfile(candidate.profile)) && candidate.roster.includes('ecaz'))) === !!game.ecazOccupyPreview &&
@@ -949,12 +968,6 @@ function simulate(
verifySampleCustody(game, expected, previous);
if (actions % 37 === 0) {
const restored = JSON.parse(JSON.stringify(game)) as Game;
for (const player of game.players)
assert.deepEqual(
viewGame(restored, player.id),
viewGame(game, player.id),
'restored view',
);
for (const player of game.players) {
const view = viewGame(restored, player.id);
for (const other of view.players.filter(
+3 -3
View File
@@ -41,10 +41,10 @@ async function main() {
'\nHomeworld-occupation opts a fresh ready 2–6-seat Basic/Advanced lobby with original selected faction decks into occupied bank Collection/immediate ally sharing, Kaitain/Junction/Richese percentage receipts, completed total Southern Hemisphere Collection, Caladan shared inspection, displaced Ix pool control, Richese card-only selection with native seller terms, Giedi receiver choice and occupied native defenses. Advanced follows the authorized supplied rulebook p22: sole foreign qualification, retained until its last force leaves; later contests/native repopulation do not expire it, and a new sole arrival after departure has a new source epoch. Basic retains unresolved lifecycle guards. Native income/peek cancellation remains distinct from printed occupied effects. Other borrowed/exceptional powers, overlays, public starts and old-game conversion remain separate.',
'\nWhen native CHOAM is seated, this fresh entry additionally marks original occupied Tupile hand slots and owner-selected normal-limit cleanup. The occupier/reciprocal ally gain one validated slot through existing capacity consumers; Advanced departure and ally removal queue the original lost-holder cleanup. Private selected-ID discards preserve current phase/opening and remaining movement, with no new card or fee. Known occupation suppresses new native low-intelligence queries; proved Advanced expiry restores eligibility without resetting historical answers/usage. Basic unknown cases retain source guards. Unmarked played profiles are not upgraded.',
'\nWhen native Ecaz is seated, this original fresh entry separately marks its printed high Homeworld alliance victory: current native seven-plus, one actual joint stronghold, two distinct other native factions with proved current occupied holdings. It adds no ordinary stronghold/Tech points, pays no reward and changes no Duke authority. Public progress remains prospective until original Mentat; qualifying members join original winners before BG prediction/final fallback. Unmarked played/public profiles remain unchanged.',
'\nDiscovery opts fresh classic, selected E1/E2 native families or standalone Ecaz OR Moritani with classics into original Discovery setup in Basic/Advanced2–6, optionally retaining unused original Tech3–6 and Advanced Strongholds2–6. 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.',
'\nDiscovery opts fresh classic, selected E1/E2 native families or standalone Ecaz OR Moritani with classics into original Discovery setup in Basic/Advanced2–6, optionally retaining unused original Tech3–6 and Advanced Strongholds2–6. Classic profiles additionally retain original Homeworlds. Original decks/cache, HMS/Face Dancers, typed entry, revealed-only nested No-Fields, stash limits, end-Mentat held claims and native outcomes remain. Advanced Moritani excludes Harkonnen. Skills/Nexus, native Homeworld combinations, paired/mixed E3, contested/shared-payer rulings, public starts and played-game conversion stay separate.',
'\nLeader-skills --discoveries explicitly composes fresh supported classic or native original family decks/all14 Skills with Discovery7/8 at2–6 seats Basic/Advanced under each existing roster predicate. Unused original Tech requires3–6; original Strongholds requires Advanced2–6. Classic profiles additionally retain original Homeworlds without Nexus. --nexus-cards retains all12 with classic factions, both original paired E1/E2 natives, or standalone Ecaz OR Moritani with classics. Actual offered training/starting hands, Maker votes/typed rides, both Advanced piles and settled-alliance end-Spice closing deals remain. Signed native pairs, trained rescue/cleanup/Tech/Face Dance and mandatory owner-labelled Ecaz or original Moritani consumers keep their sources. Mixed/E3 pairs, native Homeworld combinations/independent variants, Banker/Mentat previews, shared Duke/borrowed Smuggler/pair companion/native return and other pending rulings, public starts and save conversion remain guarded.',
'\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.',
'\nA fresh classic/base33 lobby with Homeworlds already enabled supports leader-skills, discovery or leader-skills --discoveries, Basic/Advanced2–6. Preserve original unused Tech at3+ and Advanced Strongholds at2+; both require Advanced3+. Original all14 offered training, selected Discovery7/8 and native/split Imperial pools remain. Actual Homeworld/nested battles, source-qualified rescue, Maker votes/typed reserve rides, parent-only entry, phase-end industry and source-local held effects use original consumers and controls. No Nexus/native families, public starts or played-save conversion.',
);
return;
}