Compose classic Homeworld Nexus optional modules

This commit is contained in:
Quintine committed 2026-10-07 07:14:52 +11:00
1 parent b5f5de5c83
commit c7073dc6b6
21 files changed
+1376 -71

No files matched your search

+17 -7
View File
@@ -17,13 +17,23 @@ open mode gates.
## Current checkpoint and work
**In progress — classic Homeworld Nexus composition.** The original eight
Basic/Advanced Skills/no-Skills Discovery/no-Discovery admission cases reject
before the classic-only envelope amendment and now reach original setup.
Parent reuses existing module predicates and typed reserve deposits; disjoint
skill-physical and no-Skills Discovery/lifecycle programmes are being authored.
Native Homeworld/Nexus and pending effect gates are unchanged. This next
composition has no frozen semantic/runtime/browser checkpoint yet.
**Classic Homeworld Nexus — bounded Development Verified.** Fresh original
classic Basic/Advanced2–6 now composes Homeworlds/all12 with all14 Skills,
Discovery7/8 or both, optional Tech3+/Advanced Strongholds2+. No-Skills/
no-Discovery also retains the industry/card modules. Original predicates,
setup, typed native returns and pending guards remain; native families are
separate. New13 semantic cases, affected242/242 across20 files and final
types/lint/build pass. All15 added families finish22 original six-seat
games14233/14233 actions/375 JSON on one unchanged source tree.
Actual human390px `RCGZK4CF`v167 draws a real Fremen singleton, submits paid
typed invasion/zero-Suk and exact-three return: Kaitain14, Salusa5, native
19/5, Tanks0/0, original visitor1 and unchanged return wallet6.
`A3LV8Y3U`v74 randomly reveals Shrine, enters3 Imperial counters, votes the
Maker majority, rides2 native Fremen including1 Fedaykin inside, then draws
Moritani after both actual Advanced piles and settled alliance. Atreides
losses2 do not repeat; original armies remain. All tabs closed; temporary
scripts removed. [Exact scope/source receipts](HOMEWORLD_RUNTIME_IMPLEMENTATION.md#classic-homeworld-nexus-composition--6-october-2026).
No complete-mode, final assurance, calibration or protected-deployment claim.
**Native Homeworld modules — bounded Development Verified.** Existing E1/E2 and
standalone Ecaz OR Moritani/classic setup now compose Skills, Discovery or
+15
View File
@@ -5,6 +5,21 @@ partial rules coverage. It connects physical components, real actions, private
controls, AI choices and saved continuation. Normal public Discovery and
Advanced start gates, and the publication gate, remain closed.
## Classic Homeworld Nexus composition — 6 October 2026
The original classic Nexus/Discovery entry now retains Homeworlds with
optional Tech3+/Advanced Strongholds2+. The original skill/Nexus entry also
retains Discovery7/8 and all14 training with those classic Homeworld modules.
Real free parent entry, Maker losses/votes/typed native reserve ride, both
Advanced piles and settled-alliance closing deal reuse original consumers.
A preceding Shai-Hulud's completed losses do not repeat at Great Maker.
Actual human supply/draw lotteries yield Shrine and Moritani; the human
enters with3 Imperial counters, rides2 Fremen including1 Fedaykin inside,
then draws only after both piles and alliance settlement. Source-qualified
13 cases/242 affected and22 original completed games:
[Homeworld runtime](HOMEWORLD_RUNTIME_IMPLEMENTATION.md#classic-homeworld-nexus-composition--6-october-2026).
Native Homeworld/Nexus, pending effects, public starts and full-mode acceptance remain separate.
## Native Homeworld composition — 6 October 2026
Fresh supported E1/E2 and standalone Ecaz OR Moritani/classic Discovery
+83
View File
@@ -6,6 +6,89 @@ 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 Homeworld Nexus composition — 6 October 2026
Fresh classic Basic/Advanced2–6 now composes original Homeworlds/all12 Nexus
with all14 Skills, Discovery7/8 or both, optionally original Tech3+ and
Advanced Strongholds2+. Without Skills/Discovery, original Homeworld/Nexus
additionally retains Tech and/or Advanced Strongholds. Existing `nexus`
with optional `--discoveries`, and `leader-skills --nexus-cards` with optional
`--discoveries`, remain the only private setup paths. Original base33,
private offers/hands, native placement and unchanged fresh-inventory guards
remain; no public start, new profile initializer or saved-game conversion.
Native Homeworld/Nexus optional-module families remain separate.
Eight original admission cases reject before the amendment and reach
original Traitors/Skills afterward. Three existing classic envelopes are
extended; native predicates remain authoritative. The existing
`addRevivedReserves`/`quoteNativeRevivalDeposit` already routes actual native
returns, so no second revival adapter is added. The fixed Emperor grant
uses the existing classic Nexus module predicate, including its Discovery
route. Printed Nexus faces retain their own source authority; the supplied
revision21–23 supplies the other variant and Homeworld mechanics.
Thirteen semantic cases and the affected20-file union242/242 pass;
final types/lint/build pass. Actual programmes cover qualified closing draws,
typed native/visitor Suk, original cleanup/winner Tech, death/card return,
exact-three Fremen Nexus revival, Maker casualties/votes/typed reserve ride,
both Advanced piles and next-turn parent entry. JSON continuations and all
four minimal legal policies exercise those actual windows. Support pays at
resolution before Suk, not at reveal. PDF22 grants the native strength
bonus for free, not free support for dialed forces. Ordinary stronghold
Collection income remains separate from the printed blow harvest. A preceding
Shai-Hulud may already resolve losses before Great Maker; those casualties
do not repeat. Incidental input/view copies, fake-auth/identity pins and
undefined-object/spice-property shape assertions are deleted, not re-pinned.
Fifteen new runner profiles retain stable bands8800–10200 without changing
older6400–8700 names/seeds. Families `homeworld-nexus-skills`,
`homeworld-nexus-discovery` and `homeworld-nexus-discovery-skills` optionally
add `-tech`, `-stronghold` or `-stronghold-tech`; no-Skills/no-Discovery
profiles are `homeworld-nexus-tech`, `homeworld-nexus-stronghold` and
`homeworld-nexus-stronghold-tech`. Existing prerequisite guards now include
these Homeworld classifiers: an observed unsupported Basic Stronghold
request falsely reported passed with zero samples; it now rejects, as does
two-seat Tech, before output creation. The original empty receipt remains.
Twenty-two original six-seat games across all15 families finish
14233 accepted/14233 attempted actions with375 JSON continuations and no
rejected candidates on unchanged source tree
`3f53782ade5a1824388ddc1dc552558da79ab0b2ef6354c1819ef919d07f1b08`
(HEAD `b5f5de5c83cf00e4b3006c422dad97f9d87a625c`). Every report's before/
after receipt is read. This is one frozen gameplay batch, not complete-mode/
final assurance or a later documentation-tree certificate. Private reports:
`/tmp/dune-classic-homeworld-nexus-*-six-seat-20261006`.
Human390px `RCGZK4CF` uses its original HTTP four-seat lobby/private CLI.
Its actually offered Suk on Hasimir is selected without redealing. Conserved
unplayed Spice/Nexus order selects the qualifying encounter/singleton; all
armies, alliances, losses and payments follow real actions. The human draws
Fremen after actual alliance settlement, ships3 ordinary/1 Sardaukar from
Kaitain/Salusa to Junction for4, then explicitly chooses zero Suk saves.
At next original Revival the human spends that actual Nexus for2 ordinary/
1 Sardaukar. Atv167: reserves19/5, Tanks0/0, Kaitain14 ordinary, Salusa5
Sardaukar, original Junction visitor1 ordinary, wallet6 unchanged by return.
The physical Nexus is discarded once; the original trained disc survives.
An offline auction-plan divergence is not retried: subsequent actions use
the actual current owned policy/offer. No event or outcome is staged.
Human390px `A3LV8Y3U` uses the original no-Skills Nexus/Discovery CLI,
three actual seats and Homeworld/Tech/Strongholds. Only original unplayed
Spice order is selected; the real supply lottery yields Shrine and the later
Nexus draw yields Moritani without selecting either face. The human reveals
Shrine, freely enters with the actual3-counter Imperial parent army, votes
for the real majority, and rides2 native Fremen reserves including1 Fedaykin
inside. The prior Atreides blow army goes to Tanks2 once. Both actual
Advanced piles finish before settled alliance and the sole closing draw.
Atv74: Shrine Emperor3/1 and Fremen2/1, native Southern Hemisphere6/2,
Atreides Tanks2, held Moritani; no duplicate movement, shipment or fee.
Choice screenshots, fresh state/accessibility and document width390 prove
these working-tree controls. All tabs close and temporary scripts are removed.
Native compositions, pending effect interpretations, public starts, full
combinations, comprehensive assurance/calibration and protected deployment
remain open; this bounded prototype does not shrink the complete goal.
## Native optional-module compositions — 6 October 2026
The existing fresh private entries now preserve Homeworlds with Skills,
+30
View File
@@ -1,5 +1,35 @@
# Dune implementation status
## 6 October 2026 — Classic Homeworld Nexus optional modules
Original classic Homeworld/Nexus now composes all14 Skills, Discovery7/8 or
both with optional Tech3+/Advanced Strongholds2+, and no-Skills/no-Discovery
Homeworld/Nexus retains the industry/card modules. Three existing envelopes
are amended; original setup, typed native returns and physical consumers
remain. Native optional-module families and pending rulings are not opened.
Fifteen runner profiles keep stable8800–10200 bands and older names/seeds.
New13 meaningful cases and affected242/242 across20 files, types/lint/build
pass. Incidental raw/input/view/default-shape and fake identity assertions
are removed rather than re-pinned. An observed zero-sample false pass for
unsupported Basic Strongholds is repaired using the existing prerequisite
guards; two-seat Tech also rejects before producing a report.
Twenty-two original six-seat games across all15 families finish14233/14233
actions with375 JSON continuations on one unchanged source tree.
Actual human390px `RCGZK4CF`v167 keeps its originally offered Suk, draws
Fremen after a real closing Nexus, declares a paid typed invasion and zero
rescue, then spends the actual card for2 normal/1 Sardaukar revival:
Kaitain14/Salusa5, native reserves19/5, Tanks0/0, original visitor1 and
uncharged return wallet6. `A3LV8Y3U`v74 randomly reveals Shrine, enters
with3 original Imperial counters, votes a genuine Maker majority and rides
2 native Fremen including1 Fedaykin inside, then randomly draws Moritani
after both actual Advanced piles and settled alliance. Atreides losses2
do not repeat; original source armies/custody remain.
All tabs close; temporary scripts are removed.
[Exact programme/source receipts and limits](HOMEWORLD_RUNTIME_IMPLEMENTATION.md#classic-homeworld-nexus-composition--6-october-2026).
No full-mode, comprehensive assurance, calibration or protected deployment claim.
## 6 October 2026 — Native Homeworld optional modules
Original supported native E1/E2 and standalone Ecaz OR Moritani/classic
+14
View File
@@ -5,6 +5,20 @@ Normal mode starts and publication remain gated. The [source contract](LEADER_SK
contains all fourteen physical cards; the [capture interpretation](LEADER_SKILLS_CAPTURE.md)
records the fresh publisher/designer search requested by the user.
## Classic Homeworld Nexus composition — 6 October 2026
Fresh classic all14 Skills/all12 Nexus now retains original Homeworlds,
optionally Discovery7/8, Tech3+ and Advanced Strongholds2+, through the
existing private `leader-skills --nexus-cards` entry. Native Homeworld/Nexus
optional-module families remain separate. Actual native/visitor Suk and
source-local support, skill death and original cleanup/winner Tech remain.
The next actual Fremen Nexus exact-three revival uses printed native deposit,
including Sardaukar to Salusa, not the external Suk split interpretation.
Thirteen meaningful cases, affected242/242, types/lint/build,22 original
six-seat games and actual390px typed human return are recorded in
[the source-bound Homeworld evidence](HOMEWORLD_RUNTIME_IMPLEMENTATION.md#classic-homeworld-nexus-composition--6-october-2026).
No full-skill, combined-mode, ruling, public or deployed acceptance claim.
## Native Homeworld composition — 6 October 2026
Fresh supported native E1/E2 or standalone Ecaz OR Moritani/classic entries
+9
View File
@@ -1,5 +1,14 @@
# Nexus cards: common lifecycle checkpoint
**Later classic Homeworld composition — 6 October 2026:** the original private
Nexus entry now preserves Homeworlds with optional Discovery/Tech/Advanced
Strongholds; the original skill/Nexus entry additionally retains all14
training. Native optional-module Homeworld/Nexus families remain separate.
Actual Maker/free entry/closing draw and typed fixed-three return reuse
original handlers, not a second setup or borrowed native identity.
[Source-bound13 cases/242 affected,22 original games and390px human proof](HOMEWORLD_RUNTIME_IMPLEMENTATION.md#classic-homeworld-nexus-composition--6-october-2026).
Pending effects, public starts, full combinations and deployed acceptance remain open.
**Later Harkonnen Betrayal checkpoint:** the separate fresh local
`harkonnen-betrayal` profile connects actual personal and allied native
traitor calls. Native allied counters precede provisional neutral
+17
View File
@@ -165,6 +165,23 @@ reveal history and the log now name the sourced forfeiture. Exceptional leader
custody and the independent private-step UX/public-preview gates are unchanged.
See [the cutover and Advanced sequence](MORITANI_ASSASSINATE_LEADERS.md#authority-and-advanced-duration-cutover).
## Classic Homeworld Nexus module composition — 6 October 2026
The supplied revision21–23 permits the printed Homeworld/Skills/Discovery/
Tech/Stronghold variants together or separately. Nexus faces retain their
publisher/component source authority; this composition does not derive a
Nexus effect or blanket immunity from the supplied PDF. Fresh classic
Homeworld/Nexus therefore reuses the existing optional-module predicates,
all14 offered training when selected and the original closing deal.
Physical native Suk saves stay local. Fixed-three Fremen Nexus revival
uses the existing printed native deposit, placing actual revived Sardaukar
at Salusa, separate from the external Suk split interpretation.
PDF22's free native strength bonus is not free dialed-force support.
Actual prior Shai-Hulud casualties do not repeat at Great Maker.
Native compositions, pending interpretations, public starts and full-mode
acceptance remain separate.
[Exact source-qualified scope](HOMEWORLD_RUNTIME_IMPLEMENTATION.md#classic-homeworld-nexus-composition--6-october-2026).
## Native Homeworld module composition — 6 October 2026
Authorized supplied revision physical21–23 permits Homeworlds, Skills,
+13
View File
@@ -4,6 +4,19 @@
bands to real battle resolution, player choices, AI and saved continuation.
Complete module, faction and publication gates remain closed.
## Classic Homeworld Nexus composition — 6 October 2026
Only fresh classic Homeworld skill entries additionally compose Nexus with
optional Discovery/Tech/Advanced Strongholds. Original native saves stay
local; external Imperial split remains the existing interpretation. The
human may genuinely choose zero skilled saves, leaving typed casualties
available for a later actual Fremen Nexus exact-three return. That ordinary
revival separately deposits Sardaukar at Salusa through the printed native
rule, not the Suk split. Paid wheel support settles before rescue; PDF22's
free native strength bonus does not waive support for dialed forces.
See [actual programmes,242 affected cases and human return](HOMEWORLD_RUNTIME_IMPLEMENTATION.md#classic-homeworld-nexus-composition--6-october-2026).
Native Homeworld/Nexus, Advanced Atreides/KH and other pending effects remain guarded.
## Native Homeworld composition — 6 October 2026
Supported fresh native E1/E2 and standalone Ecaz OR Moritani/classic
+2 -2
View File
@@ -73,13 +73,13 @@ export function classicDiscoveryLeaderSkillsProfile(game: LeaderSkillProfile): b
return true;
}
/** Original classic Nexus/Discovery, optionally Tech and Advanced Strongholds.
/** Original classic Nexus/Discovery, optionally Homeworlds/Tech/Advanced Strongholds.
* Setup verifies undealt inventories; live token/encounter frames are expected. */
export function classicDiscoveryNexusProfile(game: FactionModuleProfile): boolean {
if (typeof game.advanced !== 'boolean' || game.discoveryEnabled !== true ||
!game.nexusCards || game.expansions.length || game.players.length < 2 ||
game.players.length > 6 || (game.techTokens && game.players.length < 3) ||
(game.strongholdCards && !game.advanced) || game.leaderSkills || game.homeworlds ||
(game.strongholdCards && !game.advanced) || game.leaderSkills ||
game.ecazTreachery || game.semutaPreview || game.advancedPreview || game.kullPreview ||
game.nexusKullPreview || game.guildBetrayalPreview || game.richeseBetrayalPreview ||
game.nexusIxianReplacementPreview || game.nexusIxianBetrayalPreview ||
+3 -3
View File
@@ -8571,7 +8571,7 @@ export function initializeNexusGameForAudit(state: Game): Game {
requireRule(!!state.nexusCards && state.nexusCards.cards === null && state.nexusCards.phase === null,
'Enable Nexus cards in a fresh audit lobby first.');
if (state.discoveryEnabled) {
requireRule(classicDiscoveryNexusProfile(state), 'Discovery Nexus requires a fresh classic lobby with optional original Tech and Advanced Strongholds.');
requireRule(classicDiscoveryNexusProfile(state), 'Discovery Nexus requires a fresh classic lobby with optional original Homeworlds, Tech and Advanced Strongholds.');
requireFreshBaseRuntime(state);
requireFreshFactionInventory(state);
}
@@ -9540,7 +9540,7 @@ export function initializeLeaderSkillsGameForAudit(state: Game): Game {
const homeworlds = homeworldLeaderSkillsProfile(state);
if (state.homeworlds) {
requireRule(homeworlds && state.homeworlds.custody === null,
'Homeworld Leader Skills require a fresh supported classic or native lobby with only original Discovery, Tech or Advanced Stronghold modules.');
'Homeworld Leader Skills require a fresh supported classic or native lobby with original optional modules; Nexus composition requires classic factions.');
requireFreshBaseRuntime(state);
requireFreshFactionInventory(state);
}
@@ -9631,7 +9631,7 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
: ix
? 'The Ix prototype supports base, Ixian and Tleilaxu factions without optional modules.'
: homeworlds
? 'The Homeworld setup audit supports implemented deck sets without Tech Tokens or Stronghold Cards.'
? 'The Homeworld setup audit requires a fresh supported roster with original selected optional modules.'
: discoveryComposition
? 'Discovery supports fresh classic, selected E1/E2 or standalone E3 factions with optional original Tech or Advanced Strongholds and no other overlays.'
: 'The audit initializer supports base factions without expansions or optional modules.',
+4 -4
View File
@@ -37,7 +37,7 @@ export function noOtherLeaderSkillModules(game: LeaderSkillProfile): boolean {
function nexusLeaderSkillModulesSupported(game: LeaderSkillProfile, assassination = false, discovery = false): boolean {
return !!game.nexusCards && typeof game.advanced === 'boolean' &&
!!game.players && game.players.length >= 2 && game.players.length <= 6 &&
!game.homeworlds &&
(!game.homeworlds || game.expansions.length === 0) &&
((discovery && game.discoveryEnabled === true) ||
(!game.discoveries && !game.discoveryEnabled && !game.discoveryStash && !game.greatMaker)) &&
(!game.techTokens || game.players.length >= 3) &&
@@ -51,7 +51,7 @@ function nexusLeaderSkillModulesSupported(game: LeaderSkillProfile, assassinatio
!game.spiceBankerIncomePreview;
}
/** Fresh classic Nexus/Skills, with original Tech and Advanced Strongholds; native overlays stay separate. */
/** Fresh classic Nexus/Skills, optionally Homeworlds/Discovery/Tech/Advanced Strongholds. */
export function classicNexusLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
if (game.expansions.length || !nexusLeaderSkillModulesSupported(game, false, true)) return false;
const players = game.players!;
@@ -385,7 +385,7 @@ function homeworldLeaderSkillModulesSupported(game: LeaderSkillProfile): boolean
const players = game.players;
if (!game.homeworlds || typeof game.advanced !== 'boolean' ||
!players || players.length < 2 || players.length > 6 ||
game.nexusCards ||
(game.nexusCards && !classicNexusLeaderSkillsProfile(game)) ||
(game.discoveryEnabled !== true && (game.discoveryEnabled || game.discoveries || game.discoveryStash || game.greatMaker)) ||
(game.techTokens && players.length < 3) || (game.strongholdCards && !game.advanced) ||
game.ecazTreachery ||
@@ -399,7 +399,7 @@ function homeworldLeaderSkillModulesSupported(game: LeaderSkillProfile): boolean
return true;
}
/** Original classic or existing native Homeworld/Skills, optionally Discovery/Tech/Strongholds. */
/** Original Homeworld/Skills; only classic rosters additionally compose Nexus. */
export function homeworldLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
if (!game.homeworlds) return false;
if (game.expansions.length) return nativeExpansionLeaderSkillsProfile(game);
+1 -2
View File
@@ -2,7 +2,6 @@ import type { Game, Player } from './engine';
import { eliteRevivalRemaining } from './revival';
import { classicNexusModulesProfile } from './nexus-module-profile';
import { classicNexusLeaderSkillsProfile } from './leader-skill-profile';
import { classicDiscoveryNexusProfile } from './discovery-module-profile';
export const EMPEROR_NEXUS_REVIVALS = 3;
export type EmperorNexusPools = {
@@ -66,7 +65,7 @@ export function emperorNexusModeSupported(g: Game): boolean {
p.faction,
),
) &&
(classicDiscoveryNexusProfile(g) || (!!g.leaderSkills && classicNexusLeaderSkillsProfile(g)) ||
(classicNexusModulesProfile(g) || (!!g.leaderSkills && classicNexusLeaderSkillsProfile(g)) ||
(!g.homeworlds &&
(!g.leaderSkills || classicNexusLeaderSkillsProfile(g)) &&
!g.discoveryEnabled &&
+2 -2
View File
@@ -2,14 +2,14 @@ import { FACTIONS } from './catalog';
import type { FactionModuleProfile } from './faction-module-profile';
import { classicDiscoveryNexusProfile, pairedDiscoveryNexusProfile } from './discovery-module-profile';
/** Public classic configuration only; the initializer checks undealt physical components. */
/** Original classic Nexus modules, optionally Homeworlds; initializer checks undealt components. */
export function classicNexusModulesProfile(game: FactionModuleProfile): boolean {
if (game.discoveryEnabled || game.discoveries) return classicDiscoveryNexusProfile(game);
if (typeof game.advanced !== 'boolean' || !game.nexusCards ||
(!game.techTokens && !game.strongholdCards) || game.expansions.length !== 0 ||
game.players.length < 2 || game.players.length > 6 ||
(game.techTokens && game.players.length < 3) ||
(game.strongholdCards && !game.advanced) || game.leaderSkills || game.homeworlds ||
(game.strongholdCards && !game.advanced) || game.leaderSkills ||
game.discoveryEnabled || game.discoveries || game.discoveryStash || game.greatMaker ||
game.ecazTreachery || game.semutaPreview || game.advancedPreview ||
game.kullPreview || game.nexusKullPreview || game.guildBetrayalPreview ||
+14 -12
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 Homeworld prototypes compose all fourteen Leader Skills, Discovery seven cards/eight tokens or both, Basic/Advanced two through six classic or supported native E1/E2 seats, or standalone Ecaz OR Moritani with classic opponents. Original family decks and Duke/assassination exclusions remain. Optional original Tech requires three or more seats; Stronghold Cards require Advanced. Original setup/native placement, phase-end industry, source-local held cards and cleanup before mandatory winner Tech remain. Typed native Suk saves stay at the same home; external Emperor returns explicitly allocate normal/elite counters to actual native homes, an implementation interpretation rather than ordinary revival placement. Nested battles receive no Homeworld bonus; later Cistern income is separate from support/rescue. Nexus, unsupported E3 pairs/mixed families, unrelated overlays, public starts and pending interactions stay separate.',
'Fresh private Homeworld prototypes compose all fourteen Leader Skills, Discovery seven cards/eight tokens or both, Basic/Advanced two through six classic or supported native E1/E2 seats, or standalone Ecaz OR Moritani with classic opponents. Original family decks and Duke/assassination exclusions remain. Optional original Tech requires three or more seats; Stronghold Cards require Advanced. Classic factions may additionally retain all12 Nexus through the existing skill or Nexus entry. 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. Normal revival separately deposits Sardaukar at Salusa. Nested battles receive no Homeworld bonus; later Cistern income is separate from support/rescue. Native Homeworld/Nexus, unsupported E3 pairs/mixed families, unrelated overlays, public starts and pending interactions stay separate.',
'Each faction uses its own card and matching world token. In Basic play the Emperor uses Kaitain alone. Advanced play adds Salusa Secundus for its Sardaukar; movement can transfer both force types between the two Emperor worlds.',
'Native reserves determine high or low population. Salusa counts Sardaukar, while the other cards count the faction’s reserves there. Reaching the high face’s minimum restores high population. Occupation also applies the low penalty.',
'Low population normally grants one additional free force revival and one additional bank spice when collecting CHOAM Charity. Salusa grants neither bonus; CHOAM’s Tupile reverses the usual pattern by granting an advantage while low and a penalty while high.',
@@ -3984,7 +3984,7 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Verification',
status: 'Partial',
detail:
'Native Homeworld optional modules add35 semantic cases; affected180/180 and final repair union46/46, types/lint/build pass. Original six-seat games14/19 complete; twelve native families initially134/144 Advanced three-seat completions. Two preserved Ambassador deadlocks finish after the base-board bot repair; ruling captures remain, not an uninterrupted144/final-tree certificate. Human390px Ixian original card/training and typed invasion, plus native Moritani Suk rescue, retain physical custody. Dated classic14-game9008/9008/237 and occupation44/50 evidence remains in the source-bound runtime log. Full rules, combinations, final assurance and deployed acceptance remain open.',
'Classic Homeworld Nexus optional modules add13 meaningful cases; affected242/242, types/lint/build pass. Twenty-two original six-seat games across15 families finish14233/14233 actions with375 JSON on one unchanged source tree. Actual390px typed invasion/Suk/fixed-three return and random Shrine/Maker/both-pile closing draw retain source custody. Native35-case, repaired Ambassador captures and earlier classic/occupation evidence remain in canonical source logs. Not complete rules, all combinations, comprehensive assurance/calibration or deployed acceptance.',
evidence: [
'tests/homeworld-classic-skills-runtime.test.ts',
'tests/homeworld-classic-discovery-runtime.test.ts',
@@ -3992,6 +3992,8 @@ export const RULE_TOPICS: RuleTopic[] = [
'tests/homeworld-optional-modules-runtime.test.ts',
'tests/homeworld-native-e1e2-modules-runtime.test.ts',
'tests/homeworld-native-e3-modules-runtime.test.ts',
'tests/homeworld-classic-nexus-skills-runtime.test.ts',
'tests/homeworld-classic-discovery-nexus-runtime.test.ts',
'tests/homeworld-custody.test.ts',
'tests/homeworld-cards.test.ts',
'tests/homeworld-payment-income.test.ts',
@@ -4087,10 +4089,10 @@ export const RULE_TOPICS: RuleTopic[] = [
searchText: 'Hiereg Smuggler Jacurutu Sietch Cistern Ecological Testing Station Shrine Orgiz Processing Station Treachery Card Stash Spice Stash Ornithopter',
steps: [
'Fresh Discovery development setup supports classic, selected Ixian/Tleilaxu/CHOAM/Richese families, or standalone Ecaz or Moritani with classic opponents in Basic or Advanced, with seven extra Spice Cards and eight tokens. Original unused Tech Tokens may be selected with three through six seats; original Stronghold Cards require Advanced. Other modules, paired or mixed Ecaz/Moritani families and normal public starts stay gated.',
'Fresh Discovery retains Homeworlds in Basic/Advanced two through six classic or supported native E1/E2 seats, or standalone Ecaz OR Moritani with classic opponents, original seven cards/eight tokens and native reserve placement. Original Tech requires three or more seats; Stronghold Cards require Advanced. The separate original leader-skills --discoveries entry also composes all fourteen Skills. Maker, later parent entry, nested shipment/rescue, phase-end industry and cleanup before mandatory winner Tech retain actual sources; held benefits never spill into a Homeworld or unrelated nested site. Nexus with Homeworlds, unsupported E3 pairs/mixed families and unrelated overlays remain separate in this bounded profile, not outside the full goal.',
'The fresh Leader Skills entry may explicitly add Discoveries with original classic or supported native decks, all fourteen skills, original Tech at three or more seats and Advanced Stronghold Cards. Supported profiles may retain Homeworlds without Nexus. --nexus-cards additionally retains all12 with the actual settled-alliance end-Spice closing deal after Great Maker and both Advanced piles. Existing native roster and Duke/assassination/Harkonnen restrictions remain. Actual hands/offers, physical entry, nested tariffs, stash capacity, typed rescue, native aftermath and printed invoices retain their sources. Homeworlds with Nexus, unsupported E3 pairs/mixed families, independent variants and separate Banker/Mentat previews remain outside these bounded compositions.',
'Fresh Discovery retains Homeworlds in Basic/Advanced two through six classic or supported native E1/E2 seats, or standalone Ecaz OR Moritani with classic opponents, original seven cards/eight tokens and native reserve placement. Original Tech requires three or more seats; Stronghold Cards require Advanced. The separate original leader-skills --discoveries entry also composes all fourteen Skills. Classic factions additionally retain all12 Nexus through the original Nexus or skill entry. 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. Native Homeworld/Nexus, unsupported E3 pairs/mixed families and unrelated overlays remain separate in this bounded profile, not outside the full goal.',
'The fresh Leader Skills entry may explicitly add Discoveries with original classic or supported native decks, all fourteen skills, original Tech at three or more seats and Advanced Stronghold Cards. Supported profiles may retain Homeworlds without Nexus; classic factions may retain Homeworlds with Nexus. --nexus-cards 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. Native Homeworld/Nexus, unsupported E3 pairs/mixed families, independent variants and separate Banker/Mentat previews remain outside these bounded compositions.',
'The same fresh entry also adds --nexus-cards --discoveries to paired Ixians+Tleilaxu, paired CHOAM+Richese or standalone Ecaz OR Moritani with classics: original all14 offered training, all12 Nexus and Discovery7/8, Basic/Advanced two through six, optional Tech at three or more seats and Advanced Strongholds. Original setup, first hands, offers and native components stay authoritative. Real Maker losses/votes/typed rides, both Advanced piles and settled alliance precede the end-Spice closing draw. E1 Suboid Cunning settles typed Suk rescue before one equal Cyborg Tank substitution; original cleanup and mandatory winner Tech precede a matching Face Dance. E2 keeps the signed two-marker frame and one invoice. Ecaz keeps quiet living/unclaimed Duke separate from training and a real owner-labelled mandatory Occupy; Moritani keeps skill-first assassination and one private Mentat replacement. Mixed/E3 pairs, Homeworlds, shared Duke, borrowed Smuggler/pair companions, native free returns, captured/foreign training, Basic odd Ecaz and Advanced Harkonnen stay separate.',
'A separate fresh Nexus entry may explicitly add Discoveries to classic factions or both native Ixians/Tleilaxu or CHOAM/Richese with classics, one original family deck and no Skills. Original Tech requires three or more seats; Stronghold Cards require Advanced. End-Mentat ownership and next-turn physical entry remain. Great Maker finishes its losses, votes and typed reserve ride before ordinary Nexus settlement; cards are offered only at the end of the entire Spice Blow after both Advanced piles and settled alliance, not immediately or at Mentat. Source-clear return ledgers, Guild-priced nested shipment and signed native one-invoice marker pairs retain physical pieces and original capacities. E3/mixed families, Homeworlds, unrelated previews and unresolved mixed-module Fremen Betrayal stay separate from this no-Skills entry.',
'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. Only classic entries additionally retain Homeworlds. 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. Native Homeworld/Nexus, E3/mixed families, 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.',
'Great Maker first resolves the ordinary worm consequences. All players then vote in storm order on creating a Nexus. A strict majority is required; a tie adds no Nexus and an existing Nexus in the phase stays recorded. Fremen may then ride with actual reserve forces to one legal destination for free, without using ordinary shipment or movement. Storm and occupancy restrictions still apply.',
'Each of the six Discovery territory cards destroys prior spice and forces in the named blow territory, including Fremen forces, before placing six new spice subject to the storm. It also places a random available face-down token of the printed type at the card’s separate token destination.',
@@ -4130,8 +4132,8 @@ export const RULE_TOPICS: RuleTopic[] = [
},
{
area: 'Verification', status: 'Partial',
detail: 'Native Homeworld Discovery with optional all14 Skills/Tech/Advanced Strongholds adds original native setup, source-qualified parent/nested entry, capped No-Fields and physical aftermath. New35 cases; affected180/180, final repair46/46, types/lint/build pass. Twelve native families initially134/144 Advanced three-seat completions; two original Ambassador placement deadlocks finish after the bot respects existing base-board targets. CHOAM source captures and prior six-seat ruling captures remain. Human390px Ixian typed invasion and native Moritani Suk retain original armies. Dated classic/native/Nexus proofs remain separate in canonical docs; not every-effect/full combinations, assurance or deployed acceptance.',
evidence: ['tests/homeworld-native-e1e2-modules-runtime.test.ts', 'tests/homeworld-native-e3-modules-runtime.test.ts', 'tests/homeworld-classic-discovery-runtime.test.ts', 'tests/homeworld-skills-discovery-runtime.test.ts', 'tests/homeworld-optional-modules-runtime.test.ts', 'tests/discovery-native-e1-skills-runtime.test.ts', 'tests/discovery-native-e2-skills-runtime.test.ts', 'tests/discovery-native-e3-skills-runtime.test.ts', 'tests/discovery-skills-strongholds-runtime.test.ts', 'tests/discovery-native-strongholds-runtime.test.ts', 'tests/ecaz-stronghold-runtime.test.ts', 'tests/e3-native-tech-runtime.test.ts', 'tests/discovery-classic-nexus-lifecycle.test.ts', 'tests/discovery-classic-nexus-effects.test.ts', 'tests/discovery-orgiz-advanced-runtime.test.ts', 'tests/discovery-classic-skills-runtime.test.ts', 'tests/discovery-module-profile.test.ts', 'tests/discovery-native-typed-runtime.test.ts', 'tests/discovery-native-marker-runtime.test.ts', 'tests/discovery-native-e3-runtime.test.ts', 'tests/discoveries.test.ts', 'tests/discovery-runtime.test.ts', 'tests/great-maker.test.ts', 'tests/discovery-board.test.ts', 'tests/discovery-admission.test.ts', 'tests/discovery-controls.test.ts', 'tests/discovery-entry.test.ts', 'tests/discovery-entry-runtime.test.ts', 'tests/discovery-entry-controls.test.ts', 'tests/discovery-entry-review.test.ts', 'tests/discovery-collection.test.ts', 'tests/discovery-flight.test.ts', 'tests/discovery-flight-engine.test.ts', 'tests/discovery-flight-recovery.test.ts', 'tests/discovery-battle.test.ts', 'tests/discovery-battle-recovery.test.ts', 'tests/discovery-storm.test.ts', 'tests/discovery-storm-recovery.test.ts', 'tests/discovery-shrine-engine.test.ts', 'tests/discovery-shrine-recovery.test.ts', 'docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md'],
detail: 'Classic Homeworld Nexus optional modules add13 meaningful cases; affected242/242, types/lint/build pass. Twenty-two original six-seat games across15 families finish14233/14233 actions with375 JSON on one unchanged source tree. Human390px original typed invasion/zero-Suk and exact-three native return retain Kaitain14/Salusa5/visitor1; actual random Shrine entry, typed Maker ride and both-pile closing Moritani draw retain original armies. Native35-case and earlier source-qualified evidence remains in canonical logs. Native compositions, pending effects, full rules/combinations and deployed acceptance remain open.',
evidence: ['tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', 'tests/homeworld-native-e1e2-modules-runtime.test.ts', 'tests/homeworld-native-e3-modules-runtime.test.ts', 'tests/homeworld-classic-discovery-runtime.test.ts', 'tests/homeworld-skills-discovery-runtime.test.ts', 'tests/homeworld-optional-modules-runtime.test.ts', 'tests/discovery-native-e1-skills-runtime.test.ts', 'tests/discovery-native-e2-skills-runtime.test.ts', 'tests/discovery-native-e3-skills-runtime.test.ts', 'tests/discovery-skills-strongholds-runtime.test.ts', 'tests/discovery-native-strongholds-runtime.test.ts', 'tests/ecaz-stronghold-runtime.test.ts', 'tests/e3-native-tech-runtime.test.ts', 'tests/discovery-classic-nexus-lifecycle.test.ts', 'tests/discovery-classic-nexus-effects.test.ts', 'tests/discovery-orgiz-advanced-runtime.test.ts', 'tests/discovery-classic-skills-runtime.test.ts', 'tests/discovery-module-profile.test.ts', 'tests/discovery-native-typed-runtime.test.ts', 'tests/discovery-native-marker-runtime.test.ts', 'tests/discovery-native-e3-runtime.test.ts', 'tests/discoveries.test.ts', 'tests/discovery-runtime.test.ts', 'tests/great-maker.test.ts', 'tests/discovery-board.test.ts', 'tests/discovery-admission.test.ts', 'tests/discovery-controls.test.ts', 'tests/discovery-entry.test.ts', 'tests/discovery-entry-runtime.test.ts', 'tests/discovery-entry-controls.test.ts', 'tests/discovery-entry-review.test.ts', 'tests/discovery-collection.test.ts', 'tests/discovery-flight.test.ts', 'tests/discovery-flight-engine.test.ts', 'tests/discovery-flight-recovery.test.ts', 'tests/discovery-battle.test.ts', 'tests/discovery-battle-recovery.test.ts', 'tests/discovery-storm.test.ts', 'tests/discovery-storm-recovery.test.ts', 'tests/discovery-shrine-engine.test.ts', 'tests/discovery-shrine-recovery.test.ts', 'docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md'],
},
],
},
@@ -4411,8 +4413,8 @@ export const RULE_TOPICS: RuleTopic[] = [
'Before drawing, choose whether to keep your own faction card, redraw it once, or redraw whenever it appears. Its effect is available in the reference before making this choice. The server applies that preference immediately; the table never pauses in a way that identifies a card you chose to keep.',
'Entering any alliance discards your held Nexus Card, including alliances made through an Ambassador or Terror token. An offer that has not been accepted does not discard it.',
'Use Cunning for your own faction, Betrayal when another player controls the printed faction, and Secret Ally when that faction is absent. Used cards go to the separate Nexus discard pile. Each printed effect has its own timing.',
'Fresh local classic Nexus tables may preserve original Tech Tokens in Basic/Advanced three-through-six-seat lobbies and/or Stronghold Cards in Advanced two-through-six-seat lobbies; selecting both requires three or more. Original base33/all12 setup, closing Nexus deals, first-Storm Tech and actual end-Mentat Stronghold custody remain. Skills, Homeworlds in this composition, native expansions, unrelated previews, public starts and saved-game conversion stay separate.',
'Fresh nexus --discoveries preserves original Tech and Advanced Stronghold prerequisites with classic factions or both native Ixians/Tleilaxu or CHOAM/Richese and one family deck, without Skills. The original seven Discovery Spice Cards/eight tokens, protected nested locations, next-turn physical entry and Great Maker losses/votes/typed ride remain. The closing draw follows both Advanced piles and settled alliances. Source-clear revival quotas, borrowed Guild tariffs and signed native two-marker one-invoice shipments into currently revealed sites use original controls. E3/mixed families, unresolved mixed-module Fremen Betrayal and other unfinished effects remain guarded in this no-Skills entry; no public start or save conversion.',
'Fresh local classic Nexus tables may retain original Homeworlds with optional Tech Tokens in Basic/Advanced three-through-six-seat lobbies and/or Stronghold Cards in Advanced two-through-six-seat lobbies; selecting both requires three or more. Original base33/all12 setup, closing Nexus deals, first-Storm Tech and actual end-Mentat Stronghold custody remain. Separate leader-skills --nexus-cards composes all14 original training with these classic Homeworld modules, optionally Discovery. Native Homeworld/Nexus, unrelated previews, public starts and saved-game conversion remain separate.',
'Fresh nexus --discoveries preserves original Tech and Advanced Stronghold prerequisites with classic factions or both native Ixians/Tleilaxu or CHOAM/Richese and one family deck, without Skills. Only classic entries additionally retain original Homeworlds. The original seven Discovery Spice Cards/eight tokens, protected nested locations, next-turn physical entry and Great Maker losses/votes/typed native reserve ride remain. The closing draw follows both Advanced piles and settled alliances; a preceding Shai-Hulud may already have resolved the original losses, which do not repeat. Source-clear revival quotas, borrowed Guild tariffs and signed native two-marker one-invoice shipments into currently revealed sites use original controls. Native Homeworld/Nexus, E3/mixed families, unresolved mixed-module Fremen Betrayal and other unfinished effects remain guarded; no public start or save conversion.',
'The same fresh local Nexus entry also composes Tech and/or Advanced Strongholds with paired Ixians + Tleilaxu OR CHOAM + Richese and classic opponents, one original family deck and no Skills. Discovery may additionally be selected through --discoveries, including without Tech or Strongholds. Tech needs three through six seats; Strongholds needs two through six Advanced seats. Real closing draws, native typed forces, physical Face Dancers and No-Fields, original payments/rewards and held effects remain. Mixed families, E3, Homeworlds, unrelated previews, public starts and unresolved rulings are not opened.',
'The separate fresh local leader-skills --nexus-cards entry combines classic base33/all14 Skills/all12 Nexus in Basic or Advanced, two through six seats, optionally adding original Discovery7/8 through --discoveries. Original Tech requires three or more seats; Stronghold Cards require Advanced. Starting hands/offers, actual end-Spice closing draws, first-Storm Tech and end-Mentat held custody remain. Extra/ordinary free-three returns earn original phase-end Axlotl without changing skill custody. Emperor Cunning composes skill posture, support and Suk rescue; Arrakeen reduces payer cost, not committed support or rescue eligibility. Winner cleanup precedes mandatory Tech reward. Unrelated overlays and public starts remain guarded.',
'The same fresh skill/Nexus entry supports both Ixians + Tleilaxu OR both CHOAM + Richese with classics and one original family deck/all14/all12, Basic/Advanced two through six seats. Selected original Tech requires three or more; Strongholds requires Advanced. Full Suboid Cunning composes typed skill rescue before equal actual Cyborg Tank substitution. Original held subsidy changes debit, not rescue support. Card cleanup and mandatory original-winner Tech precede Face Dance; a matching trainer dies and returns its skill once without another bounty, while held cards and tokens remain with the original winner. Partial Tleilaxu refresh preserves Mentat rights. Original signed Richese pair and CHOAM fuel/industry stay distinct. Mixed families, E3, companions/mixed plans, native free-return accounting and pending rulings stay guarded.',
@@ -4467,7 +4469,7 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area: 'Player controls', status: 'Partial', detail: 'Private lifecycle, inspections, exchanges, Tleilaxu refresh, Ixian activation, advisor selection, temporary Sardaukar and CHOAM effects, Moritani Betrayal, Ecaz Betrayal and owner-only Secret Ally inquiry, native Duke Cunning, Richese shipment and bounded auction Betrayal, Emperor purchase and Fremen Cunning controls are connected. Remaining effect controls are unfinished.', evidence: ['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/nexus-ecaz-duke.tsx', 'components/nexus-richese-betrayal.tsx', 'components/game-table.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All four profiles choose legal actions through private offers, including Moritani/Ecaz Betrayal, Ecaz Secret Ally inquiry and native Duke Cunning, Fremen Cunning, Emperor purchase and bounded Richese auction Betrayal. Complete module strategy and calibration remain unfinished.', evidence: ['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/nexus-ecaz-duke-options.ts', 'game/nexus-richese-options.ts', 'game/bot-nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Common rules, twelve printed faces and bounded runtime contracts retain explicit unresolved Ecaz Duke capture/Tanks/Ghola and inquiry audience/scope, Fremen nested Cunning, Great Maker, combined modules, fresh advisors, storm and borrowed Voice. Richese auction Betrayal has a separate cost/prepayment/privacy contract and excluded-producer boundaries. Guild Betrayal separately records full funded payment, own pre-funding, donor escrow once, the printed full-Junction override, four native adapters and neutral privacy; ordinary occupation/rounding and general response-priority questions remain unresolved.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_FREMEN_RULES.md', 'docs/NEXUS_ECAZ_RULES.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_RICHESE_RULES.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md', 'docs/NEXUS_GUILD_RULES.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Physical custody, commitments, controls, legal bots, Basic/Advanced and in-memory SQLite recovery regressions include native Ecaz Duke override and private Secret Ally inquiry. A read-only local browser fixture showed the owner-only Duke choice and no rival offer; no room was written. Bounded Richese auction Betrayal additionally has local CLI/runtime, authenticated SQLite and refreshed phone Use/Pass evidence in owned disposable rooms. New Guild Betrayal verification is separately Planned pending frozen direct evidence; these earlier results do not certify it. Fresh classic and paired E1/E2 Tech/Stronghold compositions have bounded runtime cases, genuine games and original CLI/phone evidence. Classic all14 Skills additionally has actual free-return/purchase/exchange and Cunning/Suk lifecycle programs, original phone controls and genuine2..6-seat games. Full module games, exceptional Duke custody, combined interactions and deployed acceptance remain separate.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-controls.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts', 'tests/classic-nexus-skills-payments-runtime.test.ts', 'tests/classic-nexus-skills-battles-runtime.test.ts', 'tests/nexus-skills-modules-payments-runtime.test.ts', 'tests/nexus-skills-modules-battles-runtime.test.ts', 'tests/paired-ix-nexus-skills-modules-runtime.test.ts', 'tests/paired-choam-nexus-skills-modules-runtime.test.ts', 'tests/ecaz-nexus-skills-modules-runtime.test.ts', 'tests/moritani-nexus-skills-modules-runtime.test.ts'] },
{ area: 'Verification', status: 'Partial', detail: 'Classic Homeworld Nexus optional modules add13 semantic cases and affected242/242, types/lint/build. Twenty-two original six-seat games across15 families complete14233/14233 actions with375 JSON on one unchanged tree. Human390px original qualifying draw, typed invasion/zero-Suk/fixed-three native return and random Shrine/Maker/both-pile closing draw retain actual cards and armies. Earlier base/native effect, module, rule-specific private/recovery and phone receipts remain in canonical feature logs; they are not a new assurance campaign or complete-mode certificate. Native optional-module Homeworld compositions, pending interpretations, full combinations and deployed acceptance remain open.', evidence: ['tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', 'tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-controls.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts', 'tests/classic-nexus-skills-payments-runtime.test.ts', 'tests/classic-nexus-skills-battles-runtime.test.ts', 'tests/nexus-skills-modules-payments-runtime.test.ts', 'tests/nexus-skills-modules-battles-runtime.test.ts', 'tests/paired-ix-nexus-skills-modules-runtime.test.ts', 'tests/paired-choam-nexus-skills-modules-runtime.test.ts', 'tests/ecaz-nexus-skills-modules-runtime.test.ts', 'tests/moritani-nexus-skills-modules-runtime.test.ts'] },
],
},
{
@@ -4650,7 +4652,7 @@ export const RULE_TOPICS: RuleTopic[] = [
developmentStage: 'Prototyped',
summary: 'Fourteen physical skills, private assignment choices, five battle disciplines, Planetologist movement/battle effects and Suk Graduate rescues, Mentat/Bureaucrat scoring and Bureaucrat payment redirection, Sandmaster victory spice, Rihani exchanges, optional Smuggler shipping and unmodified battle collection, Sandmaster route and worm-ride collection, sealed Spice Banker battle spending and optional Diplomat base-defense copying in development tables. Other effects and combined-module acceptance remain unfinished.',
steps: [
'Fresh Leader Skills retains Homeworlds in Basic/Advanced two through six classic or supported native E1/E2 seats, or standalone Ecaz OR Moritani with classic opponents, and all fourteen original offers, optionally original Discovery7/8, Tech at three or more seats and Advanced Stronghold Cards. Native placement follows training, including the original Ixian pre-training card choice. Normal/trained Suk uses actual native, visitor or nested physical pools; native saved counters remain at that home. External Emperor returns explicitly allocate ordinary/elite counters to actual native homes, an implementation interpretation rather than ordinary revival placement. Actual residual Cyborg Tanks feed optional equal Suboid substitution after rescue. Support precedes rescue; later Cistern income, source-local held effects and mandatory winner Tech after original cleanup stay separate. Bureaucrat invoices and minimal legal bots are connected. Nexus with Homeworlds, unsupported E3 pairs/mixed families, unrelated overlays and pending interactions stay separate.',
'Fresh Leader Skills retains Homeworlds in Basic/Advanced two through six classic or supported native E1/E2 seats, or standalone Ecaz OR Moritani with classic opponents, and all fourteen original offers, optionally original Discovery7/8, Tech at three or more seats and Advanced Stronghold Cards. Only classic factions additionally compose all12 Nexus through --nexus-cards. Native placement follows training, including the original Ixian pre-training card choice. Normal/trained Suk uses actual native, visitor or nested physical pools; native saved counters remain at that home. External Emperor returns explicitly allocate ordinary/elite counters to actual native homes, an implementation interpretation rather than ordinary revival placement. Exact-three Fremen Nexus revival separately deposits ordinary Emperor counters at Kaitain and Sardaukar at Salusa through the printed native return rule. Actual residual Cyborg Tanks feed optional equal Suboid substitution after rescue. Support precedes rescue; later Cistern income, source-local held effects and mandatory winner Tech after original cleanup stay separate. Bureaucrat invoices and minimal legal bots are connected. Native Homeworld/Nexus, unsupported E3 pairs/mixed families, unrelated overlays and pending interactions stay separate.',
'Basic Moritani retains base opponents including Harkonnen. Its separate bounded Advanced classic/non-Harkonnen roster now composes the original assassination preview and all fourteen skills: winner Suk rescue and Rihani choice finish first, then an eligible different trained disc may be assassinated with its exact card return and printed bounty. No casualties repeat; original set-aside and one private Mentat replacement remain. See Moritani with Leader Skills for the exact scope.',
'Fresh Advanced profiles compose native Ixians, Tleilaxu, CHOAM and/or Richese with classic opponents and distinct required family decks. All fourteen skills and native starts remain. Auditor stays unskilled; cyborg casualties, original CHOAM support and Richese capped marker revelation remain physical. Foreign revival creates no new skill. Native Ixian Technology on Richese lots, own mixed No-Field, captured replacement, Advanced Atreides Suk/KH and unrelated overlays retain their guards.',
'Fresh three-through-six-seat Basic/Advanced development tables may compose all fourteen Skills with original Tech Tokens: classic factions, supported E1/E2 native families, or standalone Ecaz/Moritani with the exact ecaz deck and original roster exclusions. Starting cards/cache, skill/Traitor choices and printed/first-Storm assignment stay genuine. Existing leader-skills/banker-income entry preserves unused selected tokens; mixed E3 families, other overlays, public activation and saved-game conversion remain separate.',
@@ -4688,7 +4690,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:'Native Homeworld Skills optionally retains Discovery/Tech/Advanced Strongholds:35 new cases, affected180/180 and final repair46/46, types/lint/build pass. Original six-seat games14/19 complete; twelve native families initially134/144 Advanced three-seat completions, with two preserved Ambassador deadlocks completing after the canonical-target bot repair. Ruling captures remain; no uninterrupted144/final-tree certificate. Human390px actual Ixian pre-training card/training and typed native invasion, plus native Moritani Suk rescue, retain original custody. Dated classic/native skill, module and assassination proofs remain in canonical source logs. Full combinations, assurance, public/deployed acceptance stay open.',evidence:['tests/homeworld-native-e1e2-modules-runtime.test.ts','tests/homeworld-native-e3-modules-runtime.test.ts','tests/homeworld-classic-skills-runtime.test.ts','tests/homeworld-skills-discovery-runtime.test.ts','tests/homeworld-optional-modules-runtime.test.ts','tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/leader-battle-effects.test.ts','tests/suk-graduate.test.ts','tests/mentat-question.test.ts','tests/bureaucrat-payment.test.ts','tests/smuggler-battle.test.ts','tests/spice-banker.test.ts','tests/advanced-native-skills.test.ts','tests/fixture-advanced-native-skills.ts','tests/skills-tech-payments-runtime.test.ts','tests/skills-tech-battle-runtime.test.ts','tests/ix-choam-skills-tech-runtime.test.ts','tests/tleilaxu-skills-tech-runtime.test.ts','tests/richese-skills-tech-runtime.test.ts','tests/classic-skills-stronghold-runtime.test.ts','tests/native-skills-stronghold-runtime.test.ts','tests/ecaz-skills-modules-runtime.test.ts','tests/moritani-skills-modules-runtime.test.ts','docs/LEADER_SKILLS_RUNTIME.md','docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md'] },
{ area:'Verification',status:'Partial',detail:'Classic Homeworld Nexus optional modules add13 meaningful cases; affected242/242, types/lint/build pass. Twenty-two original six-seat games across15 families finish14233/14233 actions with375 JSON on one unchanged source tree. Human390px original typed invasion/zero-Suk and exact-three native return retain Kaitain14/Salusa5/visitor1; actual random Shrine entry, typed Maker ride and both-pile closing Moritani draw retain original armies. Native35-case and earlier source-qualified evidence remains in canonical logs. Native compositions, pending effects, full rules/combinations and deployed acceptance remain open.',evidence:['tests/homeworld-classic-nexus-skills-runtime.test.ts','tests/homeworld-classic-discovery-nexus-runtime.test.ts','tests/homeworld-native-e1e2-modules-runtime.test.ts','tests/homeworld-native-e3-modules-runtime.test.ts','tests/homeworld-classic-skills-runtime.test.ts','tests/homeworld-skills-discovery-runtime.test.ts','tests/homeworld-optional-modules-runtime.test.ts','tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/leader-battle-effects.test.ts','tests/suk-graduate.test.ts','tests/mentat-question.test.ts','tests/bureaucrat-payment.test.ts','tests/smuggler-battle.test.ts','tests/spice-banker.test.ts','tests/advanced-native-skills.test.ts','tests/fixture-advanced-native-skills.ts','tests/skills-tech-payments-runtime.test.ts','tests/skills-tech-battle-runtime.test.ts','tests/ix-choam-skills-tech-runtime.test.ts','tests/tleilaxu-skills-tech-runtime.test.ts','tests/richese-skills-tech-runtime.test.ts','tests/classic-skills-stronghold-runtime.test.ts','tests/native-skills-stronghold-runtime.test.ts','tests/ecaz-skills-modules-runtime.test.ts','tests/moritani-skills-modules-runtime.test.ts','docs/LEADER_SKILLS_RUNTIME.md','docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md'] },
],
},
{
+3 -10
View File
@@ -263,17 +263,10 @@ for (const configuration of [configurations[0], configurations[1], configuration
});
}
void test('authenticated native Nexus/Discovery setup preserves original seat IDs/dealt custody and excludes unrelated Emperor module profiles', () => {
const initial = initializeDiscoveryClassicNexusEffectsSetup({ advanced: true, tech: true, strongholds: true,
seatIds: ['authenticated-owner', 'authenticated-opponent', 'authenticated-third'] });
const saved = structuredClone(initial);
const accepted = initializeDiscoveryClassicNexusEffectsSetup({ initial });
assert.deepEqual(accepted, saved);
void test('original Nexus/Discovery Emperor purchase rejects unrelated profiles and preserves completed return integrity', () => {
const initial = initializeDiscoveryClassicNexusEffectsSetup({ advanced: true, tech: true, strongholds: true });
const fixture = createDiscoveryClassicNexusEffectsFixture({ initial });
assert.equal(fixture.actor, 'authenticated-owner'); assert.deepEqual(fixture.initial, saved);
assert.deepEqual(fixture.game.players.map(player => player.id), initial.players.map(player => player.id));
assert.deepEqual(initial, saved);
assert.equal(emperorNexusModeSupported(fixture.game), true);
discoveryClassicNexusEffectsInventory(fixture.game);
// Explicit adversarial compositions, not histories claimed to be natural play.
for (const change of [
(game: Game) => { game.expansions = ['ix']; },
@@ -0,0 +1,343 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, initializeNexusGameForAudit, joinGame, newPlayer, viewGame,
type Action, type Game } from '../game/engine';
import type { FactionId } from '../game/catalog';
import { location, territory } from '../game/board';
import { SPICE_CARDS } from '../game/cards';
import { DISCOVERY_CARD_PLACEMENTS, DISCOVERY_SPICE_CARDS } from '../game/discoveries';
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
import { nativeShipmentSources } from '../game/homeworld-options';
import { homeworldGameIntegrity } from '../game/homeworld-game';
import type { NativeReserveSelections } from '../game/homeworld-native-reserves';
import { classicNexusModulesProfile } from '../game/nexus-module-profile';
import { reserveShipmentCost } from '../game/shipment-price';
import { nexusInventory } from './fixture-nexus-cards';
import { classicDiscoveryNexusClean, classicDiscoveryNexusPlayer, nextClassicDiscoveryNexusStep,
withClassicDiscoveryNexusLottery, withClassicDiscoveryNexusToken,
type ClassicDiscoveryNexusStep } from './fixture-discovery-classic-nexus';
export type HomeworldClassicDiscoveryNexusStep = ClassicDiscoveryNexusStep;
export type HomeworldClassicDiscoveryNexusOptions = {
/** Original fresh lobby or its undealt native setup; never a played save. */
initial?: Game;
gameId?: string;
seatIds?: string[];
seats?: 2 | 3 | 4 | 5 | 6;
advanced?: boolean;
/** Original Discovery Spice Cards/tokens/Great Maker. Off keeps the plain Nexus deck. */
discovery?: boolean;
tech?: boolean;
strongholds?: boolean;
/** An actual original Shai-Hulud immediately before the Great Maker, not a Nexus flag write. */
ordinaryWormFirst?: boolean;
};
export type HomeworldClassicDiscoveryNexusShipment = {
actor: string; amount: number; elite: number; cost: number;
territory: string; sector: number; homeworldSources: NativeReserveSelections;
};
export type HomeworldClassicDiscoveryNexusMentat = {
before: Game; step: HomeworldClassicDiscoveryNexusStep; after: Game;
};
export type HomeworldClassicDiscoveryNexusFixture = {
initial: Game; setup: Game; afterSetup: Game; firstStorm: Game; afterBlow: Game;
beforeShipment: Game; afterShipments: Game; collection: Game;
firstMentat: HomeworldClassicDiscoveryNexusMentat; game: Game;
collector: string; victim: string; fremen: string | null; token: string | null;
parent: string; parentSector: number; blowKey: string; blowAmount: number;
wormTerritory: string; wormSource: string; amount: number; elite: number;
shipments: HomeworldClassicDiscoveryNexusShipment[];
actions: HomeworldClassicDiscoveryNexusStep[]; staging: string[];
};
export const homeworldClassicDiscoveryNexusClean = classicDiscoveryNexusClean;
export const homeworldClassicDiscoveryNexusPlayer = classicDiscoveryNexusPlayer;
/** Preserve the army under both original Guild interception producers; the
* original Discovery/Nexus native owner policy keeps every other decision. */
export function nextHomeworldClassicDiscoveryNexusStep(game: Game): HomeworldClassicDiscoveryNexusStep | null {
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard &&
(game.decision?.kind === 'guildShipment' || game.decision?.kind === 'homeworldShipmentGuild'))
return { actor: game.decision.player, action: { type: 'decision', allow: true,
...(game.decision.kind === 'homeworldShipmentGuild' ? { event: game.decision.event } : {}) } };
return nextClassicDiscoveryNexusStep(game);
}
export function applyHomeworldClassicDiscoveryNexusStep(game: Game, step: HomeworldClassicDiscoveryNexusStep,
actions?: HomeworldClassicDiscoveryNexusStep[]): Game {
actions?.push(structuredClone(step));
return withClassicDiscoveryNexusLottery(() => applyAction(game, step.actor, step.action));
}
export function advanceHomeworldClassicDiscoveryNexus(game: Game, until: (game: Game) => boolean,
actions?: HomeworldClassicDiscoveryNexusStep[]): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original lifecycle ended before its owned boundary.');
const step = nextHomeworldClassicDiscoveryNexusStep(game);
assert.ok(step, 'The original continuation requires a human sealed Battle Plan.');
game = applyHomeworldClassicDiscoveryNexusStep(game, step, actions);
}
throw new Error('The original Homeworld/Nexus lifecycle did not reach its owned boundary.');
}
/** Physical conservation of cards, ordinary counters, encountered frames and spice. */
export function homeworldClassicDiscoveryNexusInventory(game: Game): void {
homeworldGameIntegrity(game);
nexusInventory(game);
const spice = [...game.spiceDeck, ...game.spiceDiscard.flat(),
...(game.spiceResolution?.skipped ?? []), ...(game.spiceSequence?.skipped ?? [])];
if (game.discoveries) {
assert.equal(spice.length, 28);
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);
} else {
assert.equal(spice.length, 21);
assert.ok(spice.every(card => !('territory' in card) || !card.discovery));
}
assert.ok(!(game.discoveryEntry && game.nexusCards!.phase?.stage === 'drawing'));
assert.ok(!(game.greatMaker?.stage === 'vote' && game.nexusCards!.phase?.stage === 'drawing'));
}
type SpicePlan = {
blow: { territory: string; sector: number; amount: number; discovery: string | null };
parent: { territory: string; sector: number };
spare: readonly { territory: string; sector: number }[];
};
/** First three conserved original spare land blows, clear of the storm and of
* the printed blow/parent territories, so no spare doubles a harvest site. */
function originalSpareBlows(blow: string, parent: string, storm: number): { territory: string; sector: number }[] {
return SPICE_CARDS
.filter(([id, , sector]) => id !== blow && id !== parent && sector !== storm)
.slice(0, 3)
.map(([id, , sector]) => ({ territory: id, sector }));
}
/** Conserve the original physical blow/spare positions while keeping both
* printed sectors clear of the actual storm, so the real blow and its harvest
* are observable rather than storm-lost. */
function planOriginalSpice(game: Game, discovery: boolean): SpicePlan {
const storm = game.storm;
if (discovery) {
const card = DISCOVERY_SPICE_CARDS.find(candidate =>
DISCOVERY_CARD_PLACEMENTS[candidate.discovery].sector !== storm && candidate.sector !== storm);
assert.ok(card, 'An original undealt Discovery blow must keep its printed sector out of the storm.');
const placement = DISCOVERY_CARD_PLACEMENTS[card.discovery];
return { blow: { territory: card.territory, sector: card.sector, amount: card.amount,
discovery: card.discovery },
parent: { territory: placement.territory, sector: placement.sector },
spare: originalSpareBlows(card.territory, placement.territory, storm) };
}
const blow = SPICE_CARDS.find(([, , sector]) => sector !== storm);
assert.ok(blow, 'An original undealt plain blow must keep its printed sector out of the storm.');
const parent = SPICE_CARDS.find(([id, , sector]) => id !== blow[0] && sector !== storm);
assert.ok(parent);
return { blow: { territory: blow[0], sector: blow[2], amount: blow[1], discovery: null },
parent: { territory: parent[0], sector: parent[2] },
spare: originalSpareBlows(blow[0], parent[0], storm) };
}
function orderOriginalSpice(game: Game, plan: SpicePlan, options: HomeworldClassicDiscoveryNexusOptions): void {
const discovery = !!game.discoveryEnabled;
const take = (choose: (card: Game['spiceDeck'][number]) => boolean) => {
const index = game.spiceDeck.findIndex(choose);
assert.ok(index >= 0, 'Each selected physical Spice Card must remain undealt.');
return game.spiceDeck.splice(index, 1)[0];
};
const plain = (id: string) => (card: Game['spiceDeck'][number]) =>
'territory' in card && !card.discovery && card.territory === id;
// Advanced consumes one spare as the turn-one second pile; Basic consumes none.
const front = [take(card => 'territory' in card && (discovery
? card.discovery === plan.blow.discovery : !card.discovery && card.territory === plan.blow.territory))];
if (game.advanced) front.push(take(plain(plan.spare[0].territory)));
if (!discovery || options.ordinaryWormFirst) front.push(take(card => 'worm' in card && !card.greatMaker));
if (discovery) front.push(take(card => 'worm' in card && !!card.greatMaker));
front.push(take(plain(plan.spare[1].territory)), take(plain(plan.spare[2].territory)));
game.spiceDeck.unshift(...front);
}
export function finishHomeworldClassicDiscoveryNexusMentat(game: Game,
actions?: HomeworldClassicDiscoveryNexusStep[]): HomeworldClassicDiscoveryNexusMentat {
const turn = game.turn;
game = advanceHomeworldClassicDiscoveryNexus(game,
state => state.phase === 8 && homeworldClassicDiscoveryNexusClean(state), actions);
for (let count = 0; count < 100; count++) {
const before = structuredClone(game), step = nextHomeworldClassicDiscoveryNexusStep(game);
assert.ok(step);
game = applyHomeworldClassicDiscoveryNexusStep(game, step, actions);
if (game.turn !== turn || game.status === 'finished') return { before, step, after: game };
}
throw new Error('The original Mentat controls failed to close the owned turn.');
}
/** Original next-turn parent-board free entry; the reveal control decides it. */
export function enterHomeworldClassicDiscoveryNexus(game: Game, actor: string,
actions?: HomeworldClassicDiscoveryNexusStep[]): Game {
assert.equal(game.decision?.kind, 'discoveryEntry');
assert.equal(game.decision!.player, actor);
const view = viewGame(game, actor), action = discoveryEntryMoveAction(view, view.discoveryEntry!.sources);
assert.ok(action, 'The revealed parent-board army must be a legal original free entry.');
return applyHomeworldClassicDiscoveryNexusStep(game, { actor, action }, actions);
}
/** Original create/join/ready/audit initializer, real source-qualified native
* shipments, the printed blow and the Collection reveal. No hand, custody,
* clock, alliance, casualty, wallet or outcome field is written by staging. */
export function createHomeworldClassicDiscoveryNexusFixture(
options: HomeworldClassicDiscoveryNexusOptions = {},
): HomeworldClassicDiscoveryNexusFixture {
let game: Game;
if (options.initial) game = structuredClone(options.initial);
else {
const factions: FactionId[] = ['emperor', 'atreides', 'fremen', 'harkonnen', 'beneGesserit', 'guild'];
const seats = options.seats ?? options.seatIds?.length ?? 3;
assert.ok(seats >= 2 && seats <= 6);
if (options.seatIds) assert.equal(options.seatIds.length, seats);
game = createGame(options.gameId ?? 'HWCLASSICDISCOVERYNEXUS',
newPlayer(options.seatIds?.[0] ?? 'emperor', 'Emperor', 'emperor'),
options.advanced ?? !!options.strongholds);
for (let seat = 1; seat < seats; seat++)
joinGame(game, newPlayer(options.seatIds?.[seat] ?? factions[seat], factions[seat], factions[seat]));
}
assert.deepEqual(game.expansions, []);
assert.ok(!game.leaderSkills && !game.ecazTreachery && !game.spiceBankerIncomePreview);
assert.ok(game.status === 'lobby' || (game.status === 'setup' && game.turn === 1 && game.phase === 0 &&
['prediction', 'traitors'].includes(game.setupStage!) &&
game.players.every(player => !player.hand.length && !player.traitors.length)));
if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced);
const collector = game.players.find(player => player.faction === 'emperor')?.id;
const victim = game.players.find(player => player.faction === 'atreides')?.id;
assert.ok(collector && victim, 'This original programme needs its Emperor split source and an Atreides army.');
const actions: HomeworldClassicDiscoveryNexusStep[] = [];
const fresh = !options.initial;
const wantDiscovery = options.discovery ?? (fresh || !!game.discoveryEnabled);
const wantTech = options.tech ?? (fresh || !!game.techTokens);
const wantStrongholds = options.strongholds ?? (fresh ? game.advanced : !!game.strongholdCards);
if (game.status === 'lobby') {
if (!game.homeworlds) game = applyHomeworldClassicDiscoveryNexusStep(game,
{ actor: game.host, action: { type: 'homeworlds', enabled: true } }, actions);
if (wantTech && !game.techTokens) game = applyHomeworldClassicDiscoveryNexusStep(game,
{ actor: game.host, action: { type: 'techTokens', enabled: true } }, actions);
if (wantStrongholds && !game.strongholdCards) game = applyHomeworldClassicDiscoveryNexusStep(game,
{ actor: game.host, action: { type: 'strongholdCards', enabled: true } }, actions);
game.discoveryEnabled = wantDiscovery;
game.nexusCards ??= { cards: null, phase: null };
for (const player of game.players) if (!player.ready)
game = applyHomeworldClassicDiscoveryNexusStep(game, { actor: player.id, action: { type: 'ready' } }, actions);
}
assert.ok(game.homeworlds && game.nexusCards, 'This programme needs the original Homeworld and Nexus modules.');
assert.equal(!!game.discoveryEnabled, wantDiscovery);
assert.equal(!!game.techTokens, wantTech);
assert.equal(!!game.strongholdCards, wantStrongholds);
assert.ok(classicNexusModulesProfile(game));
const initial = structuredClone(game);
if (game.status === 'lobby') game = withClassicDiscoveryNexusLottery(() => initializeNexusGameForAudit(game));
const setup = structuredClone(game);
game = advanceHomeworldClassicDiscoveryNexus(game, state => state.status === 'playing', actions);
const afterSetup = structuredClone(game);
homeworldClassicDiscoveryNexusInventory(afterSetup);
game = advanceHomeworldClassicDiscoveryNexus(game,
state => state.phase === 1 && homeworldClassicDiscoveryNexusClean(state), actions);
const firstStorm = structuredClone(game);
const plan = planOriginalSpice(game, wantDiscovery);
orderOriginalSpice(game, plan, options);
// The physical token lottery must surround the real Spice Blow action.
if (wantDiscovery) for (let count = 0;
!game.discoveries!.tokens.some(token => token.face === 'cistern' && token.status === 'placed') && count < 100;
count++) {
const step = nextHomeworldClassicDiscoveryNexusStep(game); assert.ok(step);
actions.push(structuredClone(step));
game = withClassicDiscoveryNexusToken(game, 'cistern', () => applyAction(game, step.actor, step.action));
}
const afterBlow = structuredClone(game), blowKey = location(plan.blow.territory, plan.blow.sector);
const amount = game.advanced ? 3 : 2, elite = game.advanced ? 1 : 0;
game = advanceHomeworldClassicDiscoveryNexus(game,
state => state.phase === 5 && homeworldClassicDiscoveryNexusClean(state), actions);
const beforeShipment = structuredClone(game), shipments: HomeworldClassicDiscoveryNexusShipment[] = [];
let afterShipments: Game | null = null;
for (let count = 0; game.phase === 5 && count < 240; count++) {
const actor = game.active;
if (homeworldClassicDiscoveryNexusClean(game) && actor && !shipments.some(row => row.actor === actor) &&
(actor === collector || actor === victim)) {
const total = actor === collector ? amount : 2, starred = actor === collector ? elite : 0;
const sources = nativeShipmentSources(viewGame(game, actor), total, starred);
assert.ok(sources, `Original ${actor} native reserve sources must cover ${total} physical counters.`);
const target = actor === collector ? plan.parent : plan.blow;
const shipment: HomeworldClassicDiscoveryNexusShipment = { actor, amount: total, elite: starred,
territory: target.territory, sector: target.sector, homeworldSources: sources,
cost: reserveShipmentCost({ faction: homeworldClassicDiscoveryNexusPlayer(game, actor).faction, halfRate: false },
territory(target.territory).type, total) };
shipments.push(shipment);
game = applyHomeworldClassicDiscoveryNexusStep(game, { actor, action: { type: 'ship',
territory: target.territory, sector: target.sector, amount: total, elite: starred,
allyPayment: 0, homeworldSources: sources } }, actions);
if (shipments.length === 2) afterShipments = structuredClone(game);
} else {
const step = nextHomeworldClassicDiscoveryNexusStep(game); assert.ok(step);
game = applyHomeworldClassicDiscoveryNexusStep(game, step, actions);
}
}
assert.equal(shipments.length, 2);
assert.ok(afterShipments, 'Both original native shipments must be declared.');
game = advanceHomeworldClassicDiscoveryNexus(game,
state => state.phase === 7 && homeworldClassicDiscoveryNexusClean(state), actions);
const collection = structuredClone(game);
const token = game.discoveries?.tokens.find(candidate => candidate.face === 'cistern')?.id ?? null;
if (token) {
if (viewGame(game, collector).discoveries!.canInspect.includes(token))
game = applyHomeworldClassicDiscoveryNexusStep(game,
{ actor: collector, action: { type: 'discovery', token, reveal: false } }, actions);
assert.ok(viewGame(game, collector).discoveries!.canReveal.includes(token),
'The finder holding the parent-board army may reveal the placed original token.');
game = applyHomeworldClassicDiscoveryNexusStep(game,
{ actor: collector, action: { type: 'discovery', token, reveal: true } }, actions);
}
const firstMentat = finishHomeworldClassicDiscoveryNexusMentat(game, actions);
game = firstMentat.after;
assert.equal(game.turn, 2);
homeworldClassicDiscoveryNexusInventory(game);
return { initial, setup, afterSetup, firstStorm, afterBlow, beforeShipment, afterShipments,
collection, firstMentat, game, collector, victim,
fremen: game.players.find(player => player.faction === 'fremen')?.id ?? null, token,
parent: plan.parent.territory, parentSector: plan.parent.sector, blowKey, blowAmount: plan.blow.amount,
wormTerritory: plan.blow.territory, wormSource: blowKey, amount, elite, shipments, actions,
staging: [
'Original Homeworld + Nexus opt-in with no Skills; original native setup retained.',
wantDiscovery
? 'Original seven Spice Cards/eight Discovery tokens; the Cistern supply lottery is selected around the real blow.'
: 'Original plain Spice deck; this composition has no Great Maker card at all.',
'Conserved original unplayed Spice order only: the printed blow, the Advanced second pile, and the turn-two worm/Great Maker, each kept out of the actual storm.',
'Only real source-qualified native reserve shipments place the two board armies; no wallet, clock, casualty or outcome is assigned.'] };
}
/** Enter or decline the real encounter without silently voting, riding or paying. */
export function openHomeworldClassicDiscoveryNexusEncounter(fixture: HomeworldClassicDiscoveryNexusFixture,
enter = true): Game {
let game = fixture.game;
if (game.decision?.kind === 'discoveryEntry') game = enter
? enterHomeworldClassicDiscoveryNexus(game, fixture.collector, fixture.actions)
: applyHomeworldClassicDiscoveryNexusStep(game, { actor: fixture.collector,
action: { type: 'decision', event: game.decision.event, accept: false } }, fixture.actions);
return advanceHomeworldClassicDiscoveryNexus(game,
state => fixture.token ? state.greatMaker?.stage === 'vote' : !!state.nexus, fixture.actions);
}
export function voteHomeworldClassicDiscoveryNexus(game: Game, votes: readonly boolean[],
actions?: HomeworldClassicDiscoveryNexusStep[]): Game {
for (const yes of votes) {
assert.equal(game.decision?.kind, 'greatMakerVote');
game = applyHomeworldClassicDiscoveryNexusStep(game, { actor: game.decision!.player,
action: { type: 'decision', event: game.decision!.event, yes } }, actions);
}
return game;
}
/** Real settled alliance, then the single original closing Nexus draw. */
export function closeHomeworldClassicDiscoveryNexusAlliance(fixture: HomeworldClassicDiscoveryNexusFixture,
game: Game, card: 'guild' | 'ecaz' = 'ecaz'): { alliance: Game; drawing: Game; after: Game; draw: Action } {
game = advanceHomeworldClassicDiscoveryNexus(game, state => state.nexus && !state.spiceWindow &&
!state.spiceResolution && homeworldClassicDiscoveryNexusClean(state), fixture.actions);
assert.ok(fixture.fremen, 'A settled alliance needs two original seated owners.');
for (const [actor, target] of [[fixture.victim, fixture.fremen], [fixture.fremen, fixture.victim]])
game = applyHomeworldClassicDiscoveryNexusStep(game,
{ actor, action: { type: 'alliance', target } }, fixture.actions);
const alliance = structuredClone(game);
game = advanceHomeworldClassicDiscoveryNexus(game,
state => state.nexusCards!.phase?.stage === 'drawing', fixture.actions);
const deck = game.nexusCards!.cards!.deck, index = deck.indexOf(card); assert.ok(index >= 0);
deck.unshift(deck.splice(index, 1)[0]);
fixture.staging.push(`Ordered the conserved original undealt ${card} Nexus singleton before its real closing draw; no hand assigned.`);
const drawing = structuredClone(game);
const draw: Action = { type: 'nexusCardChoice', turn: game.turn,
card: game.nexusCards!.cards!.hands[fixture.collector], choice: 'draw', ownRedraws: 0 };
game = applyHomeworldClassicDiscoveryNexusStep(game, { actor: fixture.collector, action: draw }, fixture.actions);
return { alliance, drawing, after: game, draw };
}
@@ -0,0 +1,235 @@
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 { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards';
import { validateDiscoveryState } from '../game/discoveries';
import { homeworldContext } from '../game/homeworld-game';
import { homeworldForceGroups } from '../game/homeworld-custody';
import {
assertClassicHomeworldSkillsCustody, classicHomeworldSkillsPlayer,
createClassicHomeworldSkillsSetup, nextClassicHomeworldSkillsStep,
type ClassicHomeworldSkillsSetup, type ClassicHomeworldSkillsStep,
} from './fixture-homeworld-classic-skills';
import { nextHomeworldSkillsDiscoveryStep } from './fixture-homeworld-skills-discovery';
export type ClassicHomeworldNexusSkillsStep = ClassicHomeworldSkillsStep;
export type ClassicHomeworldNexusSkillsOptions = {
/** Original fresh lobby only. Never redeal an existing offer, hand or save. */
initial?: Game;
advanced?: boolean;
discovery?: boolean;
tech?: boolean;
strongholds?: boolean;
roster?: readonly FactionId[];
skill?: LeaderSkillId;
};
export type ClassicHomeworldNexusSkillsFixture = ClassicHomeworldSkillsSetup & {
kind: 'native-suk' | 'visitor-suk' | 'visitor-death';
beforeClosingDraw: Game;
closingDraw: ClassicHomeworldNexusSkillsStep;
afterClosingDraw: Game;
beforeShipment: Game;
shipment: ClassicHomeworldNexusSkillsStep;
afterShipment: Game;
beforeShippingEnd: Game;
shippingEnd: ClassicHomeworldNexusSkillsStep;
afterShippingEnd: Game;
game: Game;
owner: string;
opponent: string;
trainer: string;
territory: string;
plans: ClassicHomeworldNexusSkillsStep[];
};
export const classicHomeworldNexusSkillsPlayer = classicHomeworldSkillsPlayer;
export const classicHomeworldNexusSkillsClean = (game: Game) =>
!game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
/** Original private Nexus opt-in, not a fabricated player action. The returned
* lobby is also usable by the integration owner's original private QA route. */
export function createClassicHomeworldNexusSkillsLobby(options: ClassicHomeworldNexusSkillsOptions = {}): Game {
const roster: readonly FactionId[] = options.roster ?? ['emperor', 'guild', 'atreides', 'harkonnen'];
let game = options.initial ? structuredClone(options.initial)
: createGame('CLASSICHWNEXUSSKILLS', newPlayer(roster[0], roster[0], roster[0]), options.advanced ?? false);
if (!options.initial) for (const faction of roster.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
assert.equal(game.status, 'lobby', 'Accept only the original fresh lobby, never an existing saved setup.');
if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced);
assert.ok(game.players.every(p => ['emperor', 'guild', 'atreides', 'harkonnen', 'fremen', 'beneGesserit'].includes(p.faction)));
assert.equal(new Set(game.players.map(p => p.faction)).size, game.players.length);
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 });
if (options.discovery) game.discoveryEnabled = true;
if (!game.nexusCards) game.nexusCards = { cards: null, phase: null };
assert.equal(game.nexusCards.cards, null); assert.equal(game.nexusCards.phase, null);
return game;
}
export function createClassicHomeworldNexusSkillsSetup(options: ClassicHomeworldNexusSkillsOptions = {}): ClassicHomeworldSkillsSetup {
const initial = createClassicHomeworldNexusSkillsLobby(options), readyActions: ClassicHomeworldNexusSkillsStep[] = [];
let game = structuredClone(initial);
for (const player of game.players) if (!player.ready) {
const step: ClassicHomeworldNexusSkillsStep = { actor: player.id, action: { type: 'ready' } };
readyActions.push(step); game = applyAction(game, step.actor, step.action);
}
const setup = createClassicHomeworldSkillsSetup({ initial: game, skill: options.skill ?? 'suk-graduate' });
return { ...setup, initial, actions: [...readyActions, ...setup.actions] };
}
export function assertClassicHomeworldNexusSkillsInventory(game: Game): void {
assertClassicHomeworldSkillsCustody(game);
assert.ok(game.nexusCards?.cards);
validateNexusCards(game.nexusCards.cards, game.players);
const cards = game.nexusCards.cards;
assert.deepEqual([...cards.deck, ...cards.discard, ...Object.values(cards.hands).filter(c => c !== null)].sort(), [...NEXUS_FACTIONS].sort());
if (game.discoveryEnabled) {
assert.ok(game.discoveries); validateDiscoveryState(game.discoveries);
const spice = [...game.spiceDeck, ...game.spiceDiscard.flat(), ...(game.spiceResolution?.skipped ?? []), ...(game.spiceSequence?.skipped ?? [])];
assert.equal(spice.filter(c => 'territory' in c && !!c.discovery).length, 6);
assert.equal(spice.filter(c => 'worm' in c && c.greatMaker).length, 1);
assert.equal(game.discoveries.tokens.length, 8);
}
}
export function nextClassicHomeworldNexusSkillsStep(game: Game): ClassicHomeworldNexusSkillsStep | null {
if (!game.phaseOpening && !game.response) {
const decision = game.decision;
if (decision?.kind === 'guildShipment') return { actor: decision.player, action: { type: 'decision', allow: true } };
if (decision?.kind === 'homeworldShipmentGuild') return { actor: decision.player, action: { type: 'decision', event: decision.event, allow: true } };
if (decision?.kind === 'techToken') return null; // Mandatory human winner choice, after original cleanup.
if (game.nexusCards?.phase?.stage === 'drawing' && !decision) {
const actor = game.nexusCards.phase.eligible.find(id => !game.nexusCards!.phase!.done.includes(id));
if (actor) return { actor, action: { type: 'nexusCardChoice', turn: game.turn,
card: game.nexusCards.cards!.hands[actor], choice: 'keep', ownRedraws: 0 } };
}
}
return game.discoveryEnabled ? nextHomeworldSkillsDiscoveryStep(game)
: nextClassicHomeworldSkillsStep(game, game.players.find(p => p.faction === 'emperor')?.id);
}
export function advanceClassicHomeworldNexusSkills(state: Game, until: (game: Game) => boolean,
actions?: ClassicHomeworldNexusSkillsStep[]): Game {
let game = state;
for (let n = 0; n < 1800; n++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const next = nextClassicHomeworldNexusSkillsStep(game);
assert.ok(next, 'Original continuation stops at human sealed plans, Suk rescue or mandatory winner Tech.');
actions?.push(structuredClone(next)); game = applyAction(game, next.actor, next.action);
}
throw Error('Original Homeworld/Nexus/Skills programme did not reach its owned boundary.');
}
function act(game: Game, step: ClassicHomeworldNexusSkillsStep, actions: ClassicHomeworldNexusSkillsStep[]): Game {
actions.push(structuredClone(step)); return applyAction(game, step.actor, step.action);
}
function orderSpice(game: Game, kinds: ('land' | 'worm')[], staging: string[]): void {
for (const [position, kind] of kinds.entries()) {
const index = game.spiceDeck.findIndex((card, i) => i >= position && (kind === 'worm'
? 'worm' in card && !card.greatMaker : 'territory' in card && !card.discovery));
assert.ok(index >= position, 'Select only a conserved original unplayed Spice Card.');
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
}
staging.push(`Selected only original unplayed Spice order: ${kinds.join(', ')}; no board, wallet, hand, casualty or clock staging.`);
}
export function classicHomeworldNexusSkillsPool(game: Game, actor: string, home: string) {
return homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!)
.find(world => world.id === home)!.forces[actor] ?? { normal: 0, elite: 0 };
}
/** Full first Mentat -> second-turn natural worm -> reciprocal alliance ->
* complete Spice closure -> original private draw -> paid physical invasion.
* Every army and casualty is created by original actions. DS7/8 remain original
* components; ordinary Spice is deliberately selected so this branch has no
* incidental nested entry or Great Maker army. */
export function createClassicHomeworldNexusSkillsFixture(options: ClassicHomeworldNexusSkillsOptions & {
kind?: ClassicHomeworldNexusSkillsFixture['kind'];
} = {}): ClassicHomeworldNexusSkillsFixture {
const kind = options.kind ?? 'visitor-suk';
const setup = createClassicHomeworldNexusSkillsSetup({ ...options, skill: kind === 'visitor-death' ? 'mentat' : 'suk-graduate' });
let game = structuredClone(setup.afterSetup);
const owner = game.players.find(p => p.faction === 'emperor')!.id;
const opponent = game.players.find(p => p.faction === 'guild')!.id;
assert.equal(game.players[0].id, owner, 'This original lottery trains the actual first Emperor seat.');
assert.ok(!game.players.some(p => p.faction === 'fremen'), 'Free-three programme needs genuinely absent Fremen.');
assert.ok(game.players.length >= 4, 'Two noncombatants form a real alliance, leaving both combatants unallied.');
const trainer = game.leaderSkills!.assignments.find(a => a.owner === owner)!.leader;
orderSpice(game, game.advanced ? ['land', 'land'] : ['land'], setup.staging);
game = advanceClassicHomeworldNexusSkills(game, g => g.turn === 2 && g.phase === 1 && classicHomeworldNexusSkillsClean(g), setup.actions);
orderSpice(game, game.advanced ? ['worm', 'land', 'land'] : ['worm', 'land'], setup.staging);
let allied = false;
for (let n = 0; game.nexusCards?.phase?.stage !== 'drawing' && n < 240; n++) {
if (game.nexus && !game.spiceWindow && !game.spiceResolution && classicHomeworldNexusSkillsClean(game) && !allied) {
const others = game.players.filter(p => p.id !== owner && p.id !== opponent);
for (const [from, to] of [[others[0].id, others[1].id], [others[1].id, others[0].id]])
game = act(game, { actor: from, action: { type: 'alliance', target: to } }, setup.actions);
allied = true;
}
const next = nextClassicHomeworldNexusSkillsStep(game); assert.ok(next);
game = act(game, next, setup.actions);
}
assert.ok(allied); assert.equal(game.nexusCards?.phase?.stage, 'drawing');
assert.ok(game.nexusCards!.phase!.eligible.includes(owner));
const cards = game.nexusCards!.cards!, index = cards.deck.indexOf('fremen'); assert.ok(index >= 0);
cards.deck.unshift(cards.deck.splice(index, 1)[0]);
setup.staging.push('Selected the original undealt Fremen Nexus singleton before the qualifying real closing draw; never wrote a held Nexus hand.');
const beforeClosingDraw = structuredClone(game), closingDraw: ClassicHomeworldNexusSkillsStep = {
actor: owner, action: { type: 'nexusCardChoice', turn: game.turn, card: cards.hands[owner], choice: 'draw', ownRedraws: 0 },
};
game = act(game, closingDraw, setup.actions); const afterClosingDraw = structuredClone(game);
assert.equal(game.nexusCards!.cards!.hands[owner], 'fremen');
game = advanceClassicHomeworldNexusSkills(game, g => g.phase === 5 && g.active === (kind === 'native-suk' ? opponent : owner) && classicHomeworldNexusSkillsClean(g), setup.actions);
const territory = kind === 'native-suk' ? game.advanced ? 'homeworld:emperor:salusa' : 'homeworld:emperor' : 'homeworld:guild';
const shipper = kind === 'native-suk' ? opponent : owner;
const sources = shipper === owner && game.advanced ? {
'homeworld:emperor': { normal: 3, elite: 0 }, 'homeworld:emperor:salusa': { normal: 0, elite: 1 },
} : { [`homeworld:${classicHomeworldSkillsPlayer(game, shipper).faction}`]: { normal: kind === 'native-suk' ? 3 : 4, elite: 0 } };
const beforeShipment = structuredClone(game), shipment: ClassicHomeworldNexusSkillsStep = { actor: shipper,
action: { type: 'homeworldShip', event: viewGame(game, shipper).homeworldShipment!.event, destination: territory, sources } };
game = act(game, shipment, setup.actions);
game = advanceClassicHomeworldNexusSkills(game, g => !g.pendingHomeworldShipment && classicHomeworldNexusSkillsClean(g), setup.actions);
const afterShipment = structuredClone(game);
// Preserve the exact original phase-end action, rather than infer industry
// payment from a later wallet containing unrelated Collection/battle income.
let beforeShippingEnd: Game | undefined, shippingEnd: ClassicHomeworldNexusSkillsStep | undefined;
for (let n = 0; game.phase === 5 && n < 240; n++) {
const before = structuredClone(game), next = nextClassicHomeworldNexusSkillsStep(game); assert.ok(next);
game = act(game, next, setup.actions);
if (game.phase !== 5) { beforeShippingEnd = before; shippingEnd = next; }
}
assert.ok(beforeShippingEnd && shippingEnd);
const afterShippingEnd = structuredClone(game);
game = advanceClassicHomeworldNexusSkills(game, g => g.phase === 6 && classicHomeworldNexusSkillsClean(g), setup.actions);
const actor = game.active!; assert.ok(actor === owner || actor === opponent);
game = act(game, { actor, action: { type: 'chooseBattle', territory, target: actor === owner ? opponent : owner } }, setup.actions);
game = advanceClassicHomeworldNexusSkills(game, g => !!g.battle && classicHomeworldNexusSkillsClean(g) && !g.battle.preparation && g.battle.preLeader?.closed !== false, setup.actions);
const trained = new Set(game.leaderSkills!.assignments.map(a => a.leader));
const enemy = classicHomeworldSkillsPlayer(game, 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 weapon = kind === 'visitor-death' ? enemy.hand.find(c => c.kind === 'projectile')?.id : undefined;
if (kind === 'visitor-death') assert.ok(weapon, 'Use only the projectile genuinely dealt to the Guild at original setup.');
const dial = kind === 'visitor-death' ? 0 : kind === 'native-suk' && game.advanced ? 6 : 4;
const plans: ClassicHomeworldNexusSkillsStep[] = [
{ actor: owner, action: { type: 'battlePlan', leader: trainer, dial, support: game.advanced && dial && kind !== 'native-suk' ? 2 : 0 } },
{ actor: opponent, action: { type: 'battlePlan', leader: leader.id, dial: 0, weapon } },
];
assertClassicHomeworldNexusSkillsInventory(game);
return { ...setup, kind, beforeClosingDraw, closingDraw, afterClosingDraw, beforeShipment, shipment, afterShipment,
beforeShippingEnd, shippingEnd, afterShippingEnd, game, owner, opponent, trainer, territory, plans };
}
export function revealClassicHomeworldNexusSkillsBattle(fixture: ClassicHomeworldNexusSkillsFixture): Game {
let game = structuredClone(fixture.game);
for (const step of fixture.plans) {
game = applyAction(game, step.actor, step.action);
if (!game.battle?.revealed) game = advanceClassicHomeworldNexusSkills(game,
g => classicHomeworldNexusSkillsClean(g) && !!g.battle && !g.battle.preparation);
}
assert.ok(game.battle?.revealed); return game;
}
export function settleClassicHomeworldNexusSkillsBattle(state: Game, stop: 'suk' | 'cleanup' = 'cleanup'): Game {
return advanceClassicHomeworldNexusSkills(state, game => stop === 'suk' && game.decision?.kind === 'sukRescue' ||
game.decision?.kind === 'techToken' || !game.battle && classicHomeworldNexusSkillsClean(game));
}
export function classicHomeworldNexusSkillsRevivalWindow(state: Game, actions?: ClassicHomeworldNexusSkillsStep[], staging: string[] = []): Game {
const turn = state.turn;
let game = advanceClassicHomeworldNexusSkills(state, g => g.turn === turn + 1 && g.phase === 1 && classicHomeworldNexusSkillsClean(g), actions);
orderSpice(game, game.advanced ? ['land', 'land'] : ['land'], staging);
game = advanceClassicHomeworldNexusSkills(game, g => g.turn === turn + 1 && g.phase === 4 && classicHomeworldNexusSkillsClean(g), actions);
return game;
}
@@ -0,0 +1,294 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { discoveryEntryDeclineAction, discoveryEntryMoveAction } from '../game/discovery-entry-options';
import { greatMakerRideAction } from '../game/great-maker-options';
import { nativeShipmentSources } from '../game/homeworld-options';
import { ownedTech } from '../game/tech-tokens';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
import {
advanceHomeworldClassicDiscoveryNexus, closeHomeworldClassicDiscoveryNexusAlliance,
createHomeworldClassicDiscoveryNexusFixture, enterHomeworldClassicDiscoveryNexus,
homeworldClassicDiscoveryNexusClean, homeworldClassicDiscoveryNexusInventory,
homeworldClassicDiscoveryNexusPlayer, openHomeworldClassicDiscoveryNexusEncounter,
voteHomeworldClassicDiscoveryNexus,
} from './fixture-homeworld-classic-discovery-nexus';
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game;
function rejected(game: Game, actor: string, action: Action): void {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
/** Every original difficulty must expose one owned legal continuation and keep
* the physical counters, cards and native custody conserved. */
function legalPolicies(game: Game, owner: string): void {
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, owner);
view.players.find(player => player.id === owner)!.bot = difficulty;
const actions = botActions(view);
assert.ok(actions.length, `${difficulty} needs an owned minimal legal continuation.`);
const after = applyAction(structuredClone(game), owner, actions[0]);
homeworldClassicDiscoveryNexusInventory(after);
}
}
/** Root rulebook physical pp.22–23 and the printed Nexus/Discovery components.
* Only original create/join/ready/initializer/actions run; the tests select
* conserved unplayed Spice positions and supply lotteries, never hands,
* custody, clocks, alliances, casualty frames or outcomes. */
void test('original no-Skills Homeworld/Nexus admits Basic and Advanced classic rosters with undealt native components', () => {
const compositions = [
{ advanced: false, discovery: false, tech: true, strongholds: false },
{ advanced: false, discovery: true, tech: false, strongholds: false },
{ advanced: true, discovery: true, tech: true, strongholds: true },
{ advanced: true, discovery: false, tech: true, strongholds: true },
] as const;
for (const composition of compositions) for (const seats of [3, 6] as const) {
const fixture = createHomeworldClassicDiscoveryNexusFixture({ ...composition, seats });
assert.equal(fixture.game.turn, 2);
homeworldClassicDiscoveryNexusInventory(fixture.game);
const worlds = viewGame(fixture.game, fixture.collector).homeworlds!.worlds!;
for (const player of fixture.game.players)
assert.ok(worlds.some(world => world.native === player.id),
`Seat ${player.id} must keep its original native Homeworld.`);
assert.equal(fixture.afterSetup.status, 'playing');
if (composition.discovery) {
assert.equal(fixture.blowAmount, 6);
assert.equal(fixture.game.decision?.kind, 'discoveryEntry');
assert.equal(fixture.game.decision!.player, fixture.collector);
assert.equal(fixture.game.phase, 0);
} else {
assert.equal(fixture.game.nexus, false);
}
}
});
void test('real source-qualified native shipments fund the blow army and the Collection payment exactly once', () => {
for (const advanced of [false, true]) {
const fixture = createHomeworldClassicDiscoveryNexusFixture({ advanced, discovery: true, tech: false });
assert.equal(fixture.firstStorm.phase, 1);
assert.equal(fixture.firstStorm.techTokens ?? null, null);
assert.equal(fixture.afterBlow.spice[fixture.blowKey], fixture.blowAmount,
'The printed blow must be placed, not storm-lost.');
assert.equal(fixture.blowAmount, 6);
for (const shipment of fixture.shipments) {
const owner = homeworldClassicDiscoveryNexusPlayer(fixture.collection, shipment.actor);
const key = `${shipment.territory}:${shipment.sector}`;
assert.equal(owner.forces[key], shipment.amount);
assert.equal(owner.elites?.forces[key] ?? 0, shipment.elite);
const home = homeworldClassicDiscoveryNexusPlayer(fixture.afterSetup, shipment.actor);
assert.equal(owner.reserves, home.reserves - shipment.amount, 'Only real native counters left the reserve.');
assert.equal(owner.elites?.reserves ?? 0, (home.elites?.reserves ?? 0) - shipment.elite);
for (const source of Object.keys(shipment.homeworldSources))
assert.ok(source === `homeworld:${home.faction}` || source === 'homeworld:emperor:salusa',
'A native shipment may only spend the shipper’s own original Homeworlds.');
}
const baseline = Object.fromEntries(fixture.beforeShipment.players.map(player => [player.id, player.spice]));
const collected = Object.fromEntries(fixture.collection.players.map(player => [player.id, player.spice]));
const ordinaryIncome = quoteSpiceCollection(fixture.afterSetup).receipts;
for (const player of fixture.collection.players) {
const paid = fixture.shipments.filter(row => row.actor === player.id)
.reduce((sum, row) => sum + row.cost, 0);
const harvested: number = player.id === fixture.victim ? fixture.blowAmount : 0;
const income = ordinaryIncome.find(receipt => receipt.player === player.id)!.balance -
homeworldClassicDiscoveryNexusPlayer(fixture.afterSetup, player.id).spice;
assert.equal(collected[player.id], baseline[player.id] - paid + harvested + income,
'Shipment price and printed Collection must settle exactly once.');
}
const idle = homeworldClassicDiscoveryNexusPlayer(fixture.collection, fixture.fremen!);
assert.equal(idle.reserves, homeworldClassicDiscoveryNexusPlayer(fixture.afterSetup, fixture.fremen!).reserves);
homeworldClassicDiscoveryNexusInventory(fixture.collection);
}
});
void test('original worm losses resolve before Great Maker votes and a typed Fremen ride without repeating casualties', () => {
for (const advanced of [false, true]) {
const fixture = createHomeworldClassicDiscoveryNexusFixture({ advanced, discovery: true,
tech: advanced, ordinaryWormFirst: advanced });
const before = homeworldClassicDiscoveryNexusPlayer(fixture.game, fixture.victim);
assert.equal(before.forces[fixture.wormSource], 2);
let game = openHomeworldClassicDiscoveryNexusEncounter(fixture, false);
assert.equal(game.turn, 2);
assert.equal(game.greatMaker!.stage, 'vote');
const devoured = homeworldClassicDiscoveryNexusPlayer(game, fixture.victim);
assert.equal(devoured.forces[fixture.wormSource] ?? 0, 0);
assert.equal(devoured.tanks, before.tanks + 2);
assert.equal(devoured.reserves, before.reserves);
assert.equal(homeworldClassicDiscoveryNexusPlayer(game, fixture.collector)
.forces[`${fixture.parent}:${fixture.parentSector}`], fixture.amount,
'The army shipped to the separate parent site must survive the worm.');
const order = [...game.order];
for (let index = 0; index < order.length; index++) {
assert.equal(game.decision?.kind, 'greatMakerVote');
assert.equal(game.decision!.player, order[index]);
legalPolicies(game, order[index]);
// Advanced reaches a real majority; Basic stays a minority, so only the
// preceding original Shai-Hulud can add the Nexus there.
const vote: Action = { type: 'decision', event: game.decision!.event,
yes: advanced ? index < order.length - 1 : index === order.length - 1 };
rejected(game, order[(index + 1) % order.length], vote);
game = applyAction(game, order[index], vote);
}
assert.equal(game.nexus, advanced, 'A strict majority adds the Nexus; a tie with no preceding worm adds none.');
assert.equal(game.decision?.kind, 'greatMakerRide');
assert.equal(game.decision!.player, fixture.fremen);
const fremen = homeworldClassicDiscoveryNexusPlayer(game, fixture.fremen!);
const elite = advanced ? 1 : 0;
assert.ok(fremen.reserves >= 2 && (fremen.elites?.reserves ?? 0) >= elite);
const view = viewGame(game, fixture.fremen!), destinations = view.greatMaker!.ride!.destinations;
assert.ok(destinations.every(destination => !destination.territory.startsWith('homeworld:')));
const target = destinations.find(destination => destination.territory === 'cistern');
assert.ok(target, 'The revealed nested Cistern is a real Great Maker reserve destination.');
const ride = greatMakerRideAction(view, target.territory, target.sector, 2, elite);
assert.ok(ride);
assert.equal(greatMakerRideAction(view, 'homeworld:fremen', 0, 2, elite), null);
rejected(game, fixture.fremen!, { ...ride, territory: 'homeworld:fremen', sector: 0 });
rejected(game, fixture.fremen!, { ...ride, elite: view.greatMaker!.ride!.eliteMax + 1 });
legalPolicies(game, fixture.fremen!);
game = applyAction(game, fixture.fremen!, ride);
const after = homeworldClassicDiscoveryNexusPlayer(game, fixture.fremen!);
assert.equal(after.forces['cistern:0'], 2);
assert.equal(after.elites?.forces['cistern:0'] ?? 0, elite);
assert.equal(after.reserves, fremen.reserves - 2);
assert.equal(after.elites?.reserves ?? 0, (fremen.elites?.reserves ?? 0) - elite);
assert.equal(after.spice, fremen.spice);
assert.equal(after.shipped, fremen.shipped);
assert.equal(after.moved, fremen.moved);
assert.equal(game.greatMaker?.stage, 'complete');
assert.deepEqual(game.wormRides, []);
homeworldClassicDiscoveryNexusInventory(game);
}
});
void test('the next-turn parent-board free entry reaches the revealed nested site without shipment, movement or spice', () => {
for (const advanced of [false, true]) {
const fixture = createHomeworldClassicDiscoveryNexusFixture({ advanced, discovery: true, tech: false });
const game = fixture.game, view = viewGame(game, fixture.collector);
assert.equal(game.turn, 2);
assert.equal(game.phase, 0);
assert.equal(game.decision?.kind, 'discoveryEntry');
assert.equal(game.decision!.player, fixture.collector);
const sources = view.discoveryEntry!.sources;
assert.deepEqual(sources, [{ source: `${fixture.parent}:${fixture.parentSector}`,
normal: fixture.amount - fixture.elite, elite: fixture.elite }]);
const accept = discoveryEntryMoveAction(view, sources), decline = discoveryEntryDeclineAction(view);
assert.ok(accept && decline);
legalPolicies(game, fixture.collector);
rejected(game, fixture.fremen!, accept);
rejected(game, fixture.collector, { ...accept, groups: [{ source: 'homeworld:emperor', normal: 1, elite: 0 }] });
rejected(game, fixture.collector, { ...accept, groups: [{ ...sources[0], normal: 21 }] });
assert.equal(discoveryEntryMoveAction(view, [{ source: 'homeworld:emperor', normal: 1, elite: 0 }]), null);
assert.equal(discoveryEntryMoveAction(view, [{ ...sources[0], normal: 21 }]), null);
assert.equal(nativeShipmentSources(view, 21, 0), null);
const before = structuredClone(homeworldClassicDiscoveryNexusPlayer(game, fixture.collector));
const declined = applyAction(reload(game), fixture.collector, decline);
assert.deepEqual(homeworldClassicDiscoveryNexusPlayer(declined, fixture.collector).forces, before.forces);
const entered = enterHomeworldClassicDiscoveryNexus(reload(game), fixture.collector);
const after = homeworldClassicDiscoveryNexusPlayer(entered, fixture.collector);
assert.equal(after.forces['cistern:0'], fixture.amount);
assert.equal(after.elites?.forces['cistern:0'] ?? 0, fixture.elite);
assert.equal(after.forces[`${fixture.parent}:${fixture.parentSector}`] ?? 0, 0);
assert.equal(after.reserves, before.reserves);
assert.equal(after.elites?.reserves, before.elites?.reserves);
assert.equal(after.spice, before.spice);
assert.equal(after.shipped, before.shipped);
assert.equal(after.moved, before.moved);
rejected(entered, fixture.collector, accept);
homeworldClassicDiscoveryNexusInventory(entered);
}
});
void test('without Discovery the original worm alone creates the Nexus, and the settled alliance closes one deal', () => {
for (const advanced of [false, true]) {
const fixture = createHomeworldClassicDiscoveryNexusFixture({ advanced, discovery: false,
tech: true, strongholds: advanced });
assert.equal(fixture.token, null);
assert.equal(fixture.game.turn, 2);
assert.equal(fixture.game.nexus, false, 'No Nexus exists before the turn-two worm.');
const production = fixture.firstStorm.techTokens!.production;
assert.equal(production.owner, fixture.fremen, 'The first Storm assigns the Fremen’s original Technology.');
const heighliner = fixture.afterShipments!.techTokens!.heighliners;
if (heighliner.owner) {
assert.equal(heighliner.triggeredTurn, 1, 'A real native shipment triggers Heighliners once per phase.');
assert.equal(heighliner.spice, ownedTech(fixture.afterShipments!.techTokens, heighliner.owner).length);
}
const before = homeworldClassicDiscoveryNexusPlayer(fixture.game, fixture.victim);
assert.equal(before.forces[fixture.wormSource], 2);
const window = advanceHomeworldClassicDiscoveryNexus(fixture.game,
state => state.nexus && !state.spiceWindow && !state.spiceResolution &&
homeworldClassicDiscoveryNexusClean(state), fixture.actions);
assert.equal(window.nexus, true, 'The original Shai-Hulud creates the Nexus by itself.');
const devoured = homeworldClassicDiscoveryNexusPlayer(window, fixture.victim);
assert.equal(devoured.forces[fixture.wormSource] ?? 0, 0);
assert.equal(devoured.tanks, before.tanks + 2);
assert.equal(devoured.reserves, before.reserves);
assert.equal(homeworldClassicDiscoveryNexusPlayer(window, fixture.collector)
.forces[`${fixture.parent}:${fixture.parentSector}`], fixture.amount);
const closed = closeHomeworldClassicDiscoveryNexusAlliance(fixture, window);
assert.equal(closed.drawing.nexusCards!.phase!.stage, 'drawing');
assert.equal(closed.after.nexusCards!.cards!.hands[fixture.collector], 'ecaz');
assert.equal(closed.after.nexusCards!.phase!.stage, 'complete');
assert.equal(closed.after.nexusCards!.cards!.deck.includes('ecaz'), false);
assert.equal(homeworldClassicDiscoveryNexusPlayer(closed.alliance, fixture.victim).ally, fixture.fremen);
assert.equal(homeworldClassicDiscoveryNexusPlayer(closed.alliance, fixture.fremen!).ally, fixture.victim);
rejected(closed.after, fixture.collector, closed.draw);
homeworldClassicDiscoveryNexusInventory(closed.after);
}
});
void test('the Advanced second pile and the settled alliance close exactly one original Nexus deal', () => {
const fixture = createHomeworldClassicDiscoveryNexusFixture({ advanced: true, discovery: true,
tech: true, strongholds: true, ordinaryWormFirst: true });
let game = enterHomeworldClassicDiscoveryNexus(fixture.game, fixture.collector);
game = advanceHomeworldClassicDiscoveryNexus(game, state => state.greatMaker?.stage === 'vote', fixture.actions);
const order = [...game.order];
game = voteHomeworldClassicDiscoveryNexus(game, order.map((_, index) => index < 2));
assert.equal(game.nexus, true);
game = advanceHomeworldClassicDiscoveryNexus(game, state => state.nexus && !state.spiceWindow &&
!state.spiceResolution && homeworldClassicDiscoveryNexusClean(state), fixture.actions);
const closed = closeHomeworldClassicDiscoveryNexusAlliance(fixture, game);
assert.equal(closed.drawing.turn, 2);
assert.equal(closed.drawing.nexusCards!.phase!.stage, 'drawing');
assert.deepEqual(closed.drawing.nexusCards!.phase!.eligible, [fixture.collector]);
assert.equal(closed.after.nexusCards!.cards!.hands[fixture.collector], 'ecaz');
assert.equal(closed.after.nexusCards!.cards!.discard.includes('ecaz'), false);
assert.equal(homeworldClassicDiscoveryNexusPlayer(closed.after, fixture.collector).spice,
homeworldClassicDiscoveryNexusPlayer(closed.drawing, fixture.collector).spice);
assert.equal(closed.after.nexusCards!.phase!.stage, 'complete');
assert.equal(closed.after.strongholdCards!.claimedTurn, 1);
assert.deepEqual(closed.after.wormRides, []);
rejected(closed.after, fixture.collector, closed.draw);
homeworldClassicDiscoveryNexusInventory(closed.after);
});
void test('all four minimal legal policies answer the real free entry, vote, ride and closing draw', () => {
const fixture = createHomeworldClassicDiscoveryNexusFixture({ advanced: true, discovery: true,
tech: true, strongholds: true });
const vote = openHomeworldClassicDiscoveryNexusEncounter(fixture, false);
const ride = voteHomeworldClassicDiscoveryNexus(reload(vote), [...vote.order].map(() => true));
const closed = closeHomeworldClassicDiscoveryNexusAlliance(fixture, ride);
const cases = [
{ game: fixture.game, owner: fixture.collector, type: 'decision' },
{ game: vote, owner: vote.decision!.player, type: 'decision' },
{ game: ride, owner: ride.decision!.player, type: 'decision' },
{ game: closed.drawing, owner: fixture.collector, type: 'nexusCardChoice' },
];
for (const difficulty of DIFFICULTIES) for (const current of cases) {
const view = viewGame(current.game, current.owner);
view.players.find(player => player.id === current.owner)!.bot = difficulty;
const action = botActions(view).find(candidate => candidate.type === current.type);
assert.ok(action, `${difficulty} must answer the owned ${current.game.decision?.kind ?? 'closing Nexus draw'}.`);
const after = applyAction(reload(current.game), current.owner, action);
if (current.game.decision?.kind === 'greatMakerVote')
assert.equal(after.greatMaker!.votes.length, current.game.greatMaker!.votes.length + 1);
else if (current.game.decision?.kind === 'greatMakerRide')
assert.equal(after.greatMaker!.stage, 'complete');
else if (current.game.decision?.kind === 'discoveryEntry')
rejected(after, current.owner, action);
else assert.equal(after.nexusCards!.phase!.stage, 'complete');
homeworldClassicDiscoveryNexusInventory(after);
}
});
@@ -0,0 +1,228 @@
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 type { HomeworldCombatLossContext } from '../game/homeworld-combat-loss';
import { quoteSukPhysicalRescue } from '../game/suk-graduate';
import { strongholdBenefit } from '../game/stronghold-cards';
import { ownedTech } from '../game/tech-tokens';
import { classicHomeworldSkillsBotOffers } from './fixture-homeworld-classic-skills';
import { homeworldSkillsDiscoveryRescueChoice, homeworldSkillsDiscoverySplitReturn } from './fixture-homeworld-skills-discovery';
import {
advanceClassicHomeworldNexusSkills as advance,
assertClassicHomeworldNexusSkillsInventory as inventory,
classicHomeworldNexusSkillsClean as clean,
classicHomeworldNexusSkillsPlayer as player,
classicHomeworldNexusSkillsPool as pool,
classicHomeworldNexusSkillsRevivalWindow as revivalWindow,
createClassicHomeworldNexusSkillsFixture as fixture,
createClassicHomeworldNexusSkillsLobby as lobby,
revealClassicHomeworldNexusSkillsBattle as reveal,
settleClassicHomeworldNexusSkillsBattle as settle,
} from './fixture-homeworld-classic-nexus-skills';
/** Root physical PDF21–23 authorizes original variant composition. These are
* actual original phase programmes, not a configuration matrix. External Suk
* allocations use the existing placement interpretation; printed normal revival
* is separately asserted to return Sardaukar to Salusa, never that Suk split. */
function legalPolicies(game: Game, actor: string): void {
for (const difficulty of DIFFICULTIES) {
const actions = classicHomeworldSkillsBotOffers(game, actor, difficulty);
assert.ok(actions.length, `${difficulty} needs a real owned continuation.`);
inventory(applyAction(structuredClone(game), actor, actions[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 } };
}) };
}
/** Original loser played-card cleanup must already be committed before the
* winner is asked to choose Tech. No synthetic winner receipt is introduced. */
function finishWinnerTech(state: Game): Game {
if (state.decision?.kind !== 'techToken') { inventory(state); return state; }
const decision = state.decision, before = structuredClone(state.techTokens);
legalPolicies(state, decision.player);
assert.equal(state.battle, null, 'Original battle disposal precedes the reward choice.');
assert.equal(state.pendingSukRescue ?? null, null);
assert.equal(state.pendingWinnerDiscards ?? null, null);
for (const entry of state.lastBattleContext!.mandatoryDiscard?.entries ?? [])
assert.ok(state.discard.some(card => card.id === entry.card), 'Original played-card cleanup precedes mandatory winner Tech.');
const token = decision.choices[0], action: Action = { type: 'decision', token };
reject(state, decision.loser, action);
reject(state, decision.player, { type: 'decision', decline: true });
const done = settle(applyAction(JSON.parse(JSON.stringify(state)), decision.player, action));
assert.equal(done.techTokens![token].owner, decision.player);
for (const [id, old] of Object.entries(before!)) if (id !== token)
assert.deepEqual(done.techTokens![id as keyof NonNullable<typeof before>], old);
inventory(done); return done;
}
void test('genuine worm/alliance closing draw precedes a paid native withdrawal, local support and one original Shipping-end industry payment', () => {
const original = lobby({ advanced: true, discovery: true, tech: true, strongholds: true });
const f = fixture({ initial: original, kind: 'visitor-suk' });
assert.equal(player(f.beforeClosingDraw, f.owner).ally, null);
legalPolicies(f.beforeClosingDraw, f.owner);
reject(f.beforeClosingDraw, f.beforeClosingDraw.players.find(p => p.ally)!.id, f.closingDraw.action);
reject(f.beforeClosingDraw, f.owner, { ...f.closingDraw.action, turn: f.beforeClosingDraw.turn - 1 });
assert.equal(f.beforeClosingDraw.nexusCards!.cards!.hands[f.owner], null);
assert.equal(f.afterClosingDraw.nexusCards!.cards!.hands[f.owner], 'fremen');
assert.ok(!f.afterClosingDraw.nexusCards!.cards!.deck.includes('fremen'));
assert.equal(f.afterClosingDraw.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 0);
legalPolicies(f.beforeShipment, f.owner);
const before = player(f.beforeShipment, f.owner), after = player(f.afterShipment, f.owner);
assert.equal(after.reserves, before.reserves - 4);
assert.equal(after.elites!.reserves, before.elites!.reserves - 1);
assert.equal(after.spice, before.spice - 4, 'One original Homeworld invasion invoice, not Arrakis or doubled payment.');
assert.deepEqual(pool(f.afterShipment, f.owner, f.territory), { normal: 3, elite: 1 });
assert.deepEqual(pool(f.beforeShipment, f.owner, 'homeworld:emperor'), {
normal: pool(f.afterShipment, f.owner, 'homeworld:emperor').normal + 3, elite: 0,
});
assert.equal(pool(f.afterShipment, f.owner, 'homeworld:emperor:salusa').elite,
pool(f.beforeShipment, f.owner, 'homeworld:emperor:salusa').elite - 1);
const token = f.beforeShippingEnd.techTokens!.heighliners;
assert.equal(token.spice, 1); assert.equal(token.triggeredTurn, 2);
legalPolicies(f.beforeShippingEnd, f.shippingEnd.actor);
assert.equal(player(f.afterShippingEnd, token.owner!).spice, player(f.beforeShippingEnd, token.owner!).spice + token.spice);
assert.equal(f.afterShippingEnd.techTokens!.heighliners.spice, 0);
assert.equal(f.afterShippingEnd.techTokens!.axlotl.spice, 0);
assert.equal(f.afterShippingEnd.techTokens!.production.spice, 0);
assert.equal(f.game.strongholdCards!.owners.tueks_sietch, f.opponent, 'Original Guild board occupation earned the retained public card at first Mentat.');
assert.equal(strongholdBenefit(f.game.strongholdCards, f.opponent, f.territory), null, 'Held Tuek support is source-local, not a benefit on Guild Homeworld.');
for (const actor of [f.owner, f.opponent]) legalPolicies(f.game, actor);
const changed = { ...f, plans: f.plans.map(step => step.actor === f.opponent
? { actor: step.actor, action: { ...step.action, dial: 2, support: 1 } } : step) };
const revealed = reveal(changed), pending = settle(revealed, 'suk');
assert.equal(player(pending, f.owner).spice, player(f.game, f.owner).spice - 2,
'The invading army pays its actual support before Suk rescue.');
assert.equal(player(pending, f.opponent).spice, player(f.game, f.opponent).spice - 1,
'The free native battle-strength bonus does not waive ordinary support or borrow the held Tuek subsidy.');
inventory(pending);
});
for (const kind of ['native-suk', 'visitor-suk'] as const) {
void test(`Advanced original ${kind} saves its actual typed source under HW/Nexus/DS/Tech/Stronghold composition`, () => {
const f = fixture({ advanced: true, discovery: true, tech: true, strongholds: true, kind });
const revealed = reveal(f), pending = settle(revealed, 'suk');
assert.deepEqual(pending.techTokens, revealed.techTokens, 'Real winner Tech ownership cannot move ahead of the owned Suk cleanup.');
reject(f.game, f.owner, { type: 'battlePlan', leader: f.trainer,
dial: kind === 'native-suk' ? 12 : 8, support: 0 });
assert.ok(pending.decision?.kind === 'sukRescue');
const decision = pending.decision, receipt = pending.pendingSukRescue!;
const choice = homeworldSkillsDiscoveryRescueChoice(pending, kind === 'native-suk' ? 'elite' : 'normal');
const option = decision.options[choice], destinations = homeworldSkillsDiscoverySplitReturn(pending, choice);
assert.equal(receipt.territory, f.territory); assert.equal(receipt.skill.mode, 'skilled');
assert.equal(receipt.pool.length, 1); assert.equal(receipt.pool[0].key, f.territory);
assert.equal(option.normal + option.elite, 3);
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 action: Action = { type: 'decision', event: decision.event, choice, ...(destinations ? { destinations } : {}) };
reject(pending, f.opponent, action);
reject(pending, f.owner, { ...action, event: 'stale-physical-rescue' });
reject(pending, f.owner, { ...action, destinations: { 'homeworld:guild': { normal: 1, elite: 1 } } });
if (kind === 'native-suk') {
assert.equal(decision.reserveHomes, undefined);
assert.equal(receipt.losses!.normal, 0); assert.equal(receipt.losses!.elite, 3);
reject(pending, f.owner, { ...action, destinations: { 'homeworld:emperor': { normal: 0, elite: 3 } } });
} else {
assert.equal(receipt.losses!.normal, 2); assert.equal(receipt.losses!.elite, 1);
assert.deepEqual(destinations, { 'homeworld:emperor': { normal: 0, elite: 1 }, 'homeworld:emperor:salusa': { normal: 1, elite: 0 } });
reject(pending, f.owner, { type: 'decision', event: decision.event, choice });
reject(pending, f.owner, { ...action, destinations: { 'homeworld:emperor': { normal: 0, elite: 1 } } });
}
const resolved = settle(applyAction(JSON.parse(JSON.stringify(pending)), f.owner, action));
assert.deepEqual(resolved.homeworlds!.custody, physical.custody);
assert.equal(player(resolved, f.owner).reserves, physical.players.find(p => p.id === f.owner)!.reserves);
assert.equal(player(resolved, f.owner).elites!.reserves, physical.players.find(p => p.id === f.owner)!.eliteReserves);
assert.equal(player(resolved, f.owner).tanks - player(pending, f.owner).tanks,
receipt.losses!.normal + receipt.losses!.elite - option.normal - option.elite);
assert.equal(resolved.pendingSukRescue ?? null, null);
assert.equal(player(resolved, f.owner).leaders.find(l => l.id === f.trainer)!.dead, false);
assert.ok(resolved.leaderSkills!.assignments.some(a => a.owner === f.owner && a.leader === f.trainer && a.skill === 'suk-graduate'));
if (kind === 'native-suk') assert.deepEqual(resolved.homeworlds!.custody!.salusa, pending.homeworlds!.custody!.salusa,
'Saved native Sardaukar remain at the exact native Salusa source, with no internal move.');
else assert.deepEqual(pool(resolved, f.owner, f.territory), { normal: 2, elite: 0 });
const done = finishWinnerTech(resolved);
for (const token of ownedTech(revealed.techTokens, f.opponent))
assert.equal(done.techTokens![token].owner, f.owner, 'Original losing-owner Tech transfers only after real rescue and card cleanup.');
assert.equal(done.lastBattleContext!.winner, f.owner); inventory(done);
});
}
for (const advanced of [false, true]) {
void test(`${advanced ? 'Advanced typed' : 'Basic ordinary'} real zero-save Suk losses fund exact-three Fremen Nexus return at the next actual Revival`, () => {
const f = fixture({ advanced, discovery: advanced, tech: true, strongholds: advanced });
const pending = settle(reveal(f), 'suk'); assert.ok(pending.decision?.kind === 'sukRescue');
const choice = pending.decision.options.findIndex(option => !option.normal && !option.elite && !option.kept);
assert.ok(choice >= 0, 'The skilled original human option may genuinely decline all rescue.');
legalPolicies(pending, f.owner);
const done = finishWinnerTech(settle(applyAction(pending, f.owner, { type: 'decision', event: pending.decision.event, choice })));
assert.equal(player(done, f.owner).tanks, advanced ? 3 : 4);
assert.equal(player(done, f.owner).elites?.tanks ?? 0, advanced ? 1 : 0);
assert.equal(done.nexusCards!.cards!.hands[f.owner], 'fremen', 'Battle rescue never spends the unrelated physically held Nexus singleton.');
const window = revivalWindow(done, f.actions, f.staging), offer = viewGame(window, f.owner).nexusFremenRevival!;
assert.equal(offer.blocked, null); assert.ok(offer.eliteOptions.includes(advanced ? 1 : 0));
legalPolicies(window, f.owner);
const action: Action = { type: 'nexusFremenRevive', event: offer.event, elite: advanced ? 1 : 0 };
reject(window, f.opponent, action);
reject(window, f.owner, { ...action, event: 'stale-free-three' });
reject(window, f.owner, { ...action, elite: 2 });
const before = structuredClone(player(window, f.owner));
const kaitain = pool(window, f.owner, 'homeworld:emperor');
const salusa = advanced ? pool(window, f.owner, 'homeworld:emperor:salusa') : null;
const paid = applyAction(JSON.parse(JSON.stringify(window)), f.owner, action), after = player(paid, f.owner);
assert.equal(after.spice, before.spice, 'Free-three is not charged as paid revival or external Suk placement.');
assert.equal(after.tanks, before.tanks - 3); assert.equal(after.reserves, before.reserves + 3);
assert.equal(after.revived, 3); assert.equal(after.freeForcesRevived, 3);
assert.deepEqual(pool(paid, f.owner, 'homeworld:emperor'), { normal: kaitain.normal + (advanced ? 2 : 3), elite: kaitain.elite });
if (advanced) {
assert.deepEqual(pool(paid, f.owner, 'homeworld:emperor:salusa'), { normal: salusa!.normal, elite: salusa!.elite + 1 });
assert.equal(after.elites!.tanks, before.elites!.tanks - 1);
assert.equal(after.elites!.reserves, before.elites!.reserves + 1);
assert.equal(after.elites!.revived, 1);
}
assert.equal(paid.nexusCards!.cards!.hands[f.owner], null);
assert.equal(paid.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1);
reject(paid, f.owner, action);
reject(paid, f.owner, { type: 'revive', amount: 1, elite: 0 });
const token = paid.techTokens!.axlotl, holder = token.owner!;
assert.equal(token.spice, ownedTech(paid.techTokens, holder).length);
const end = advance(paid, g => g.phase === 4 && clean(g) && g.ready.length === g.players.length - 1);
const actor = end.players.find(p => !end.ready.includes(p.id))!.id;
legalPolicies(end, actor);
const ended = applyAction(end, actor, { type: 'ready' });
assert.equal(player(ended, holder).spice, player(end, holder).spice + token.spice);
assert.equal(ended.techTokens!.axlotl.spice, 0); inventory(ended);
});
}
void test('real lethal visitor battle returns the trained public Skill once before any original winner Tech reward', () => {
const f = fixture({ advanced: true, discovery: true, tech: true, strongholds: true, kind: 'visitor-death' });
const weapon = f.plans.find(step => step.actor === f.opponent)!.action.weapon as string;
const resolved = settle(reveal(f));
assert.equal(player(resolved, f.owner).leaders.find(l => l.id === f.trainer)!.dead, true);
assert.ok(!resolved.leaderSkills!.assignments.some(a => a.leader === f.trainer));
assert.equal(resolved.leaderSkills!.deck.filter(skill => skill === 'mentat').length, 1);
assert.equal(player(resolved, f.owner).tanks, 4);
assert.deepEqual(pool(resolved, f.owner, f.territory), { normal: 0, elite: 0 });
const done = finishWinnerTech(resolved);
for (const token of ownedTech(f.game.techTokens, f.owner))
assert.equal(done.techTokens![token].owner, f.opponent, 'Killed trained disc and losing army settle before the original winner reward.');
assert.equal(done.lastBattleContext!.winner, f.opponent);
assert.ok(player(done, f.opponent).hand.some(card => card.id === weapon), 'Original winner may keep the genuinely played projectile through ordinary cleanup.');
const later = advance(done, g => g.turn > done.turn);
assert.equal(later.leaderSkills!.deck.filter(skill => skill === 'mentat').length, 1);
inventory(later);
});
+46 -26
View File
@@ -56,6 +56,15 @@ const HOMEWORLD_MODULE_PROFILES = [
'native-homeworld-discovery-skills', 'native-homeworld-discovery-skills-tech',
'native-homeworld-discovery-skills-stronghold', 'native-homeworld-discovery-skills-stronghold-tech',
] as const;
const HOMEWORLD_NEXUS_MODULE_PROFILES = [
'homeworld-nexus-skills', 'homeworld-nexus-skills-tech',
'homeworld-nexus-skills-stronghold', 'homeworld-nexus-skills-stronghold-tech',
'homeworld-nexus-discovery', 'homeworld-nexus-discovery-tech',
'homeworld-nexus-discovery-stronghold', 'homeworld-nexus-discovery-stronghold-tech',
'homeworld-nexus-discovery-skills', 'homeworld-nexus-discovery-skills-tech',
'homeworld-nexus-discovery-skills-stronghold', 'homeworld-nexus-discovery-skills-stronghold-tech',
'homeworld-nexus-tech', 'homeworld-nexus-stronghold', 'homeworld-nexus-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'
@@ -70,7 +79,8 @@ type Profile = 'base' | 'choam' | 'ecaz' | 'ecaz-treachery' | 'ecaz-occupy' | 'm
| 'paired-discovery-nexus' | 'paired-discovery-nexus-tech' | 'paired-discovery-nexus-stronghold' | 'paired-discovery-nexus-stronghold-tech'
| 'paired-discovery-nexus-skills' | 'paired-discovery-nexus-skills-tech' | 'paired-discovery-nexus-skills-stronghold' | 'paired-discovery-nexus-skills-stronghold-tech'
| 'e3-discovery-nexus-skills' | 'e3-discovery-nexus-skills-tech' | 'e3-discovery-nexus-skills-stronghold' | 'e3-discovery-nexus-skills-stronghold-tech'
| (typeof HOMEWORLD_MODULE_PROFILES)[number];
| (typeof HOMEWORLD_MODULE_PROFILES)[number]
| (typeof HOMEWORLD_NEXUS_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' ||
@@ -93,8 +103,10 @@ 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 homeworldNexusSampleProfile = (profile: string) =>
(HOMEWORLD_NEXUS_MODULE_PROFILES as readonly string[]).includes(profile);
const homeworldModuleSampleProfile = (profile: string) =>
(HOMEWORLD_MODULE_PROFILES as readonly string[]).includes(profile);
(HOMEWORLD_MODULE_PROFILES as readonly string[]).includes(profile) || homeworldNexusSampleProfile(profile);
const homeworldSkillsSampleProfile = (profile: string) =>
homeworldModuleSampleProfile(profile) && profile.includes('-skills');
const homeworldDiscoverySampleProfile = (profile: string) =>
@@ -182,6 +194,11 @@ const HOMEWORLD_CLASSIC_MODULE_SCENARIOS: readonly Scenario[] =
BASE_SCENARIOS.map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 6400 + band * 100 + index }))
.filter(scenario => (!homeworldTechSampleProfile(profile) || scenario.roster.length >= 3) &&
(!homeworldStrongholdSampleProfile(profile) || scenario.rules === 'advanced')));
const HOMEWORLD_CLASSIC_NEXUS_MODULE_SCENARIOS: readonly Scenario[] =
HOMEWORLD_NEXUS_MODULE_PROFILES.flatMap((profile, band) =>
BASE_SCENARIOS.map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 8800 + band * 100 + index }))
.filter(scenario => (!homeworldTechSampleProfile(profile) || scenario.roster.length >= 3) &&
(!homeworldStrongholdSampleProfile(profile) || scenario.rules === 'advanced')));
const NEXUS_SKILLS_SCENARIOS: readonly Scenario[] = BASE_SCENARIOS.map((scenario, index) => ({
...scenario, profile: 'nexus-skills', ordinal: 1410 + index,
}));
@@ -238,6 +255,7 @@ const discoveryNexusProfile = (profile: string) => profile === 'discovery-nexus'
profile === 'discovery-nexus-stronghold-tech' || discoveryNexusSkillsProfile(profile) ||
pairedDiscoveryNexusSampleProfile(profile) || nativeDiscoveryNexusSkillsProfile(profile);
const nexusModuleProfile = (profile: string) =>
homeworldNexusSampleProfile(profile) ||
profile === 'nexus-tech' || profile === 'nexus-stronghold' || profile === 'nexus-stronghold-tech' ||
discoveryNexusProfile(profile) || pairedNexusModuleProfile(profile) || nexusSkillsModuleProfile(profile) || pairedNexusSkillsModuleProfile(profile) || e3NexusSkillsProfile(profile);
const nexusTechProfile = (profile: string) =>
@@ -669,6 +687,7 @@ function usage() {
+ '\nDiscovery-nexus uses classic/base33/all12 Nexus/Discovery7+8, Basic/Advanced2..6. Discovery-nexus-tech adds original Tech3+; discovery-nexus-stronghold adds Advanced Strongholds2+; both requires3+ Advanced. Original Great Maker/worm/vote/ride, settled-alliance end-Spice closing deal, end-Mentat held-card settlement, next-turn entry and source-clear borrowed force/tariff/card effects remain. No Skills/HW/native families, mixed-module Fremen Betrayal, new pending rulings, public starts or save conversion.'
+ '\nHomeworld-skills, homeworld-discovery and homeworld-discovery-skills use original classic/base33 rosters in Basic/Advanced2..6, all14 offered training when Skills is selected and Discovery7/8 when Discovery is selected. Each family may add -tech (original Tech3+), -stronghold (Advanced2+) or -stronghold-tech (Advanced3+). Original setup/cards/native force sources, physical rescue, Maker/entry, phase-end industry and source-local held effects remain. No Nexus, native families, public starts, save conversion or pending-rule changes.'
+ '\nNative-homeworld-skills, native-homeworld-discovery and native-homeworld-discovery-skills retain each existing E1/E2 or standalone Ecaz/Moritani roster/deck predicate and original Homeworld pools, Basic/Advanced2..6. Optional -tech requires3+; -stronghold requiresAdvanced2+; -stronghold-tech requiresAdvanced3+. Original private starts, all14 when selected, DS7/8 when selected, physical Suk/residual Cyborg exchange, native aftermath and held/industry/reward timing remain. Nexus, E3pairs/mixed families, unrelated previews, public starts, save conversion and pending rulings remain separate.'
+ '\nHomeworld-nexus-skills, homeworld-nexus-discovery and homeworld-nexus-discovery-skills compose original classic/base33/all12 with optional all14 training and Discovery7/8, Basic/Advanced2..6. Each family adds -tech at3+, -stronghold inAdvanced2+ or -stronghold-tech inAdvanced3+. Without Skills/Discovery, homeworld-nexus-tech, homeworld-nexus-stronghold and homeworld-nexus-stronghold-tech retain the same prerequisites. Original native Homeworld pools, real closing draws, printed revival deposits, physical Suk and phase-end industry/held rewards remain. Native families, unrelated previews, unresolved effects, public starts and save conversion stay guarded.'
);
}
@@ -802,7 +821,9 @@ function freshGame(scenario: Scenario) {
if (homeworldDiscoverySampleProfile(scenario.profile) || nativeDiscoverySkillsProfile(scenario.profile) || discoverySkillsProfile(scenario.profile) || discoveryNexusProfile(scenario.profile)) game.discoveryEnabled = true;
if (discoveryProfile(scenario.profile)) {
game.discoveryEnabled = true;
return initializeDiscoveryGameForAudit(game);
return homeworldNexusSampleProfile(scenario.profile)
? initializeNexusGameForAudit(game)
: initializeDiscoveryGameForAudit(game);
}
if (scenario.profile === 'homeworld-occupation')
return initializeHomeworldOccupationGameForAudit(game);
@@ -869,25 +890,23 @@ function resumedGame(path: string) {
game.mentatQuestionPreview
)
throw new Error('--resume sample scenarios exclude unsupported optional modules.');
const scenario = [...SCENARIOS, ...COMBINED_NEXUS_SCENARIOS, ...COMBINED_HOMEWORLD_NEXUS_SCENARIOS, ...BASE_SCENARIOS, ...MODULE_SCENARIOS, ...EXPANSION_ROSTER_SCENARIOS, ...ECAZ_TREACHERY_SCENARIOS, ...ECAZ_OCCUPY_SCENARIOS, ...PAIRED_NEXUS_SCENARIOS, ...MORITANI_ASSASSINATE_SCENARIOS, ...STRONGHOLD_FACTIONS_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...IX_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS, ...RICHESE_SKILLS_SCENARIOS, ...ECAZ_SKILLS_SCENARIOS, ...SKILLS_TECH_SCENARIOS, ...SKILLS_STRONGHOLD_SCENARIOS, ...NATIVE_TECH_SCENARIOS, ...CLASSIC_NEXUS_MODULE_SCENARIOS, ...PAIRED_NEXUS_MODULE_SCENARIOS, ...NEXUS_SKILLS_SCENARIOS, ...PAIRED_NEXUS_SKILLS_SCENARIOS, ...NEXUS_SKILLS_MODULE_SCENARIOS, ...PAIRED_NEXUS_SKILLS_MODULE_SCENARIOS, ...E3_NEXUS_SKILLS_SCENARIOS, ...HOMEWORLD_OCCUPATION_SCENARIOS, ...DISCOVERY_SCENARIOS, ...DISCOVERY_SKILLS_SCENARIOS, ...DISCOVERY_NEXUS_SCENARIOS, ...DISCOVERY_STRONGHOLD_SCENARIOS, ...NATIVE_DISCOVERY_SKILLS_SCENARIOS, ...DISCOVERY_NEXUS_SKILLS_SCENARIOS, ...PAIRED_DISCOVERY_NEXUS_SCENARIOS, ...NATIVE_DISCOVERY_NEXUS_SKILLS_SCENARIOS, ...HOMEWORLD_CLASSIC_MODULE_SCENARIOS, ...HOMEWORLD_NATIVE_MODULE_SCENARIOS].find(
(candidate) =>
(homeworldDiscoverySampleProfile(candidate.profile) || nativeDiscoverySkillsProfile(candidate.profile) || discoveryProfile(candidate.profile) || discoverySkillsProfile(candidate.profile) || discoveryNexusProfile(candidate.profile)) === discovery &&
(homeworldModuleSampleProfile(candidate.profile) || candidate.profile === 'homeworld' || candidate.profile === 'homeworld-occupation' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.homeworlds &&
(candidate.profile === 'paired-nexus-skills' || candidate.profile === 'nexus-skills' || candidate.profile === 'nexus' || nexusModuleProfile(candidate.profile) || candidate.profile === 'homeworld-nexus' || candidate.profile === 'choam-nexus' || candidate.profile === 'ecaz-nexus' || candidate.profile === 'ix-nexus' || candidate.profile === 'combined-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.nexusCards &&
(candidate.profile === 'homeworld-occupation') === !!game.homeworldOccupationPreview &&
skillProfile(candidate.profile) === !!game.leaderSkills &&
(homeworldTechSampleProfile(candidate.profile) || discoveryTechProfile(candidate.profile) || candidate.profile === 'skills-tech' || candidate.profile === 'skills-stronghold-tech' || nativeTechProfile(candidate.profile) || nexusTechProfile(candidate.profile)) === !!game.techTokens &&
(homeworldStrongholdSampleProfile(candidate.profile) || discoveryStrongholdProfile(candidate.profile) || nativeStrongholdProfile(candidate.profile) || strongholdSkillProfile(candidate.profile) || nexusStrongholdProfile(candidate.profile)) === !!game.strongholdCards &&
(candidate.profile === 'ecaz-treachery' || candidate.ecazTreachery === true) === !!game.ecazTreachery &&
assassinationScenario(candidate) === !!game.moritaniAssassinatePreview &&
(candidate.profile === 'ecaz-occupy' || ((homeworldModuleSampleProfile(candidate.profile) || nativeDiscoveryNexusSkillsProfile(candidate.profile) || nativeDiscoverySkillsProfile(candidate.profile) || nativeTechProfile(candidate.profile) || discoveryProfile(candidate.profile)) && candidate.roster.includes('ecaz'))) === !!game.ecazOccupyPreview &&
assassinationScenario(candidate) === !!game.moritaniAssassinate &&
candidate.rules === (game.advanced ? 'advanced' : 'basic') &&
JSON.stringify(candidate.expansions) ===
JSON.stringify(game.expansions) &&
JSON.stringify(candidate.roster) ===
JSON.stringify(game.players.map((player) => player.faction)),
);
const scenario = [...SCENARIOS, ...HOMEWORLD_CLASSIC_NEXUS_MODULE_SCENARIOS, ...COMBINED_NEXUS_SCENARIOS, ...COMBINED_HOMEWORLD_NEXUS_SCENARIOS, ...BASE_SCENARIOS, ...MODULE_SCENARIOS, ...EXPANSION_ROSTER_SCENARIOS, ...ECAZ_TREACHERY_SCENARIOS, ...ECAZ_OCCUPY_SCENARIOS, ...PAIRED_NEXUS_SCENARIOS, ...MORITANI_ASSASSINATE_SCENARIOS, ...STRONGHOLD_FACTIONS_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...IX_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS, ...RICHESE_SKILLS_SCENARIOS, ...ECAZ_SKILLS_SCENARIOS, ...SKILLS_TECH_SCENARIOS, ...SKILLS_STRONGHOLD_SCENARIOS, ...NATIVE_TECH_SCENARIOS, ...CLASSIC_NEXUS_MODULE_SCENARIOS, ...PAIRED_NEXUS_MODULE_SCENARIOS, ...NEXUS_SKILLS_SCENARIOS, ...PAIRED_NEXUS_SKILLS_SCENARIOS, ...NEXUS_SKILLS_MODULE_SCENARIOS, ...PAIRED_NEXUS_SKILLS_MODULE_SCENARIOS, ...E3_NEXUS_SKILLS_SCENARIOS, ...HOMEWORLD_OCCUPATION_SCENARIOS, ...DISCOVERY_SCENARIOS, ...DISCOVERY_SKILLS_SCENARIOS, ...DISCOVERY_NEXUS_SCENARIOS, ...DISCOVERY_STRONGHOLD_SCENARIOS, ...NATIVE_DISCOVERY_SKILLS_SCENARIOS, ...DISCOVERY_NEXUS_SKILLS_SCENARIOS, ...PAIRED_DISCOVERY_NEXUS_SCENARIOS, ...NATIVE_DISCOVERY_NEXUS_SKILLS_SCENARIOS, ...HOMEWORLD_CLASSIC_MODULE_SCENARIOS, ...HOMEWORLD_NATIVE_MODULE_SCENARIOS].find((candidate) =>
(homeworldDiscoverySampleProfile(candidate.profile) || nativeDiscoverySkillsProfile(candidate.profile) || discoveryProfile(candidate.profile) || discoverySkillsProfile(candidate.profile) || discoveryNexusProfile(candidate.profile)) === discovery &&
(homeworldModuleSampleProfile(candidate.profile) || candidate.profile === 'homeworld' || candidate.profile === 'homeworld-occupation' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.homeworlds &&
(candidate.profile === 'paired-nexus-skills' || candidate.profile === 'nexus-skills' || candidate.profile === 'nexus' || nexusModuleProfile(candidate.profile) || candidate.profile === 'homeworld-nexus' || candidate.profile === 'choam-nexus' || candidate.profile === 'ecaz-nexus' || candidate.profile === 'ix-nexus' || candidate.profile === 'combined-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.nexusCards &&
(candidate.profile === 'homeworld-occupation') === !!game.homeworldOccupationPreview &&
skillProfile(candidate.profile) === !!game.leaderSkills &&
(homeworldTechSampleProfile(candidate.profile) || discoveryTechProfile(candidate.profile) || candidate.profile === 'skills-tech' || candidate.profile === 'skills-stronghold-tech' || nativeTechProfile(candidate.profile) || nexusTechProfile(candidate.profile)) === !!game.techTokens &&
(homeworldStrongholdSampleProfile(candidate.profile) || discoveryStrongholdProfile(candidate.profile) || nativeStrongholdProfile(candidate.profile) || strongholdSkillProfile(candidate.profile) || nexusStrongholdProfile(candidate.profile)) === !!game.strongholdCards &&
(candidate.profile === 'ecaz-treachery' || candidate.ecazTreachery === true) === !!game.ecazTreachery &&
assassinationScenario(candidate) === !!game.moritaniAssassinatePreview &&
(candidate.profile === 'ecaz-occupy' || ((homeworldModuleSampleProfile(candidate.profile) || nativeDiscoveryNexusSkillsProfile(candidate.profile) || nativeDiscoverySkillsProfile(candidate.profile) || nativeTechProfile(candidate.profile) || discoveryProfile(candidate.profile)) && candidate.roster.includes('ecaz'))) === !!game.ecazOccupyPreview &&
assassinationScenario(candidate) === !!game.moritaniAssassinate &&
candidate.rules === (game.advanced ? 'advanced' : 'basic') &&
JSON.stringify(candidate.expansions) ===
JSON.stringify(game.expansions) &&
JSON.stringify(candidate.roster) ===
JSON.stringify(game.players.map((player) => player.faction)),);
if (!scenario)
throw new Error(
'--resume does not match a fixed base, module or expansion sample scenario.',
@@ -1087,15 +1106,16 @@ async function main() {
throw new Error(`--profile ${profile} supports only five or six players.`);
if (profile === 'moritani-assassinate' && rules === 'basic')
throw new Error('Moritani assassination samples require Advanced rules.');
if ((profile === 'stronghold-factions' || strongholdSkillProfile(profile) || nexusStrongholdProfile(profile)) && rules === 'basic')
if ((homeworldStrongholdSampleProfile(profile) || profile === 'stronghold-factions' || strongholdSkillProfile(profile) || nexusStrongholdProfile(profile)) && rules === 'basic')
throw new Error('Stronghold samples require Advanced rules.');
if ((profile === 'skills-tech' || profile === 'skills-stronghold-tech') && players === 2)
throw new Error('Leader Skills with Tech Tokens samples require three through six players.');
if ((homeworldTechSampleProfile(profile) || profile === 'skills-tech' || profile === 'skills-stronghold-tech') && players === 2)
throw new Error('Tech Tokens samples require three through six players.');
if ((discoveryTechProfile(profile) || (discoveryNexusProfile(profile) && nexusTechProfile(profile))) && players === 2)
throw new Error('Discovery with Tech Tokens requires three through six players.');
const resume = values.resume ? resumedGame(values.resume) : null;
const requestedSamples = homeworldModuleSampleProfile(profile)
? (profile.startsWith('native-') ? HOMEWORLD_NATIVE_MODULE_SCENARIOS : HOMEWORLD_CLASSIC_MODULE_SCENARIOS)
? (homeworldNexusSampleProfile(profile) ? HOMEWORLD_CLASSIC_NEXUS_MODULE_SCENARIOS
: profile.startsWith('native-') ? HOMEWORLD_NATIVE_MODULE_SCENARIOS : HOMEWORLD_CLASSIC_MODULE_SCENARIOS)
: nativeDiscoveryNexusSkillsProfile(profile) ? NATIVE_DISCOVERY_NEXUS_SKILLS_SCENARIOS
: discoveryNexusSkillsProfile(profile) ? DISCOVERY_NEXUS_SKILLS_SCENARIOS
: pairedDiscoveryNexusSampleProfile(profile) ? PAIRED_DISCOVERY_NEXUS_SCENARIOS
+3 -3
View File
@@ -42,9 +42,9 @@ async function main() {
'\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 original Homeworlds, unused Tech3–6 and Advanced Strongholds2–6. Original decks/cache, HMS/Face Dancers, typed entry, revealed-only nested No-Fields, stash limits, end-Mentat claims and native outcomes remain. Advanced Moritani excludes Harkonnen. Skills/Nexus, paired/mixed E3, contested/shared-payer rulings, public starts and played-game conversion stay separate.',
'\nLeader-skills --discoveries composes fresh supported classic or native original family decks/all14 Skills with Discovery7/8 at2–6 seats Basic/Advanced under each existing roster predicate. Original Homeworlds may be retained without Nexus; Tech requires3–6 and Strongholds Advanced2–6. --nexus-cards separately retains all12 with classic factions, paired E1/E2 natives or standalone Ecaz OR Moritani with classics, without Homeworlds. Original private card choices/training/hands, Maker/votes/rides, physical residual Cyborg exchange, native aftermath and cleanup/Tech/Face Dance retain their own timing. E3pairs/mixed families, independent variants, Banker/Mentat previews, shared Duke/borrowed Smuggler/pair companion/native returns and pending rulings remain separate.',
'\nNexus --discoveries explicitly composes fresh classic/base33 OR both native Ixians+Tleilaxu/Ix47 OR CHOAM+Richese/CHOAM35+cache with all12 Nexus and original Discovery7/8 at2–6 seats Basic/Advanced. Unused Tech requires3+; optional Strongholds require Advanced; both3+. Actual Great Maker losses/votes/typed reserve rides, both Advanced piles, settled-alliance end-Spice closing deals and original held claims remain. Signed native two-marker nested shipments retain one printed invoice, current revealed destination and original reserve materialization. Native Skills, HW, E3/mixed families, mixed-module Fremen Betrayal, pending rulings, public activation and save conversion stay guarded.',
'\nFresh supported classic or native E1/E2/standalone E3 lobbies with original Homeworlds support leader-skills, discovery or leader-skills --discoveries, Basic/Advanced2–6. Preserve unused Tech3+ and Advanced Strongholds2+; both requireAdvanced3+. Native roster/deck/assassination/Duke boundaries remain. Original all14/DS7+8 when selected, private native starts, split Imperial and typed native/visitor/nested pools, phase-end industry, held effects and winner rewards use original consumers. Nexus, unsupported E3pair/mixed rosters, unrelated previews, public starts and save conversion remain separate.',
'\nLeader-skills --discoveries composes fresh supported classic or native original family decks/all14 Skills with Discovery7/8 at2–6 seats Basic/Advanced under each existing roster predicate. Original Homeworlds may be retained without Nexus, or with Nexus for classic factions only; Tech requires3–6 and Strongholds Advanced2–6. --nexus-cards retains all12 with classic factions, paired E1/E2 natives or standalone Ecaz OR Moritani with classics; native entries exclude Homeworlds. Original private card choices/training/hands, Maker/votes/rides, physical residual Cyborg exchange, native aftermath and cleanup/Tech/Face Dance retain their own timing. E3pairs/mixed families, independent variants, Banker/Mentat previews, shared Duke/borrowed Smuggler/pair companion/native returns and pending rulings remain separate.',
'\nNexus --discoveries explicitly composes fresh classic/base33 OR both native Ixians+Tleilaxu/Ix47 OR CHOAM+Richese/CHOAM35+cache with all12 Nexus and original Discovery7/8 at2–6 seats Basic/Advanced. Classic entries may retain original Homeworlds; native entries may not. 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 with Homeworlds, E3/mixed families, mixed-module Fremen Betrayal, pending rulings, public activation and save conversion stay guarded.',
'\nFresh supported classic or native E1/E2/standalone E3 lobbies with original Homeworlds support leader-skills, discovery or leader-skills --discoveries, Basic/Advanced2–6. Preserve unused Tech3+ and Advanced Strongholds2+; both requireAdvanced3+. Only classic entries additionally compose Nexus through leader-skills --nexus-cards with optional --discoveries, or nexus with optional --discoveries and original Tech/Strongholds. Native roster/deck/assassination/Duke boundaries remain. Original all14/DS7+8 when selected, private native starts, split Imperial and typed native/visitor/nested pools, real closing draws, printed native revival deposits, phase-end industry, held effects and winner rewards use original consumers. Native Homeworld/Nexus, unsupported E3pair/mixed rosters, unrelated previews, public starts and save conversion remain separate.',
);
return;
}