Compose original single-native E1 E2 Nexus modules and skills

This commit is contained in:
Quintine committed 2026-10-07 11:50:25 +11:00
1 parent 204d91902d
commit 30a0bdbbb6
19 files changed
+1802 -53

No files matched your search

+19 -1
View File
@@ -1,6 +1,6 @@
# Current development status
Updated 6 October 2026. **The full twelve-faction goal is unfinished. Basic
Updated 7 October 2026. **The full twelve-faction goal is unfinished. Basic
is playable and the six classic factions have a user-authorized Advanced preview.
Expansion starts and full rules/publication acceptance remain gated.**
@@ -17,6 +17,24 @@ open mode gates.
## Current checkpoint and work
**Single-native E1/E2 Nexus — bounded Development Verified; publication pending.** Original Ixians OR
Tleilaxu OR CHOAM OR Richese/classic entries compose Nexus with Skills or no
Skills, optional Homeworld/Discovery/Tech3+/Advanced Strongholds2+ and one
family deck.192 original admissions reach real setup versus eight before;
paired predicates remain both-native. New15 semantic programmes and
affected398/398 across35 files plus types/lint pass. The actual bare-Skills
Smuggler deadlock is repaired in the shared ordinary-skill predicate.
Across32 original six-seat families,190/192 complete208,205accepted/
208,213attempted/5,539JSON across two frozen trees including one preserved
capture continuation; exhausted-cache/low-Tupile ruling stops remain.
Actual390px original Ixian trained offer/typed Cistern entry/Cunning/free
Suboid plan/equal Cyborg substitution and no-training Richese one-invoice
five-plus-three pair/reveal preserve20 counters in **4CR7JHUL v160** and
**LNHZXDEE v102**. Final types/lint/build and actual compiled390px guide
pass; the owned QA reload retains both versions/pools. Exact pushed-revision
publication receipts remain pending; no full-mode or protected deployment claim.
[Canonical qualified evidence](NEXUS_CARD_RULES.md#single-native-e1e2-nexus-composition--7-october-2026).
**Standalone E3 Nexus without Skills — bounded Development Verified.**
Original Ecaz OR Moritani/classic ecaz33/all12 composes optional Homeworld/
Discovery/Tech3+/Advanced Strongholds2+ through the existing generic entry.
+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.
## Single-native E1/E2 Nexus composition — 7 October 2026
Original single-native Ixians OR Tleilaxu/Ix47, or CHOAM OR
Richese/CHOAM35 plus its native cache, now retain Discovery7/8/all12 Nexus
with classics, optionally all14 Skills/Homeworlds/Tech3+/Advanced
Strongholds2+. Real original parent entry, Maker/both-pile closing deals,
typed nested rescue, signed one-invoice Richese materialization and
source-local held/industry/winner effects are reused, not adapted.
[Canonical source-qualified192-admission/15-programme/398-check evidence](NEXUS_CARD_RULES.md#single-native-e1e2-nexus-composition--7-october-2026).
Original32-family games finish190/192 across two frozen trees, including
one repaired original Smuggler continuation; two printed ruling stops
remain. Actual human typed Cistern entry, Maker/both-pile closing,
Suboid Cunning/equal exchange and signed5+3/reveal preserve20 counters.
Mixed native families, public starts and pending printed rulings remain separate.
## Standalone E3 Nexus without Skills — 6 October 2026
Original Ecaz OR Moritani/classic `nexus --discoveries` now composes
+15
View File
@@ -6,6 +6,21 @@ 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).
## Single-native E1/E2 Nexus composition — 7 October 2026
Original Homeworld/Nexus now composes exactly one Ixians OR Tleilaxu,
or CHOAM OR Richese, with classics and one family deck, with or without
Skills. Discovery7/8, Tech3+ and Advanced Strongholds2+ remain optional.
Typed native, visitor and nested rescue, residual Cyborg substitution,
printed own-disc revival training, physical Face Dance and signed Richese
one-invoice marker pairs reuse the original consumers.
[Canonical source-qualified verification](NEXUS_CARD_RULES.md#single-native-e1e2-nexus-composition--7-october-2026)
records192 setup admissions,15 programmes/398 affected checks and190/192
completed original games across two frozen trees. Actual390px native typed
Cistern entry/equal Cyborg exchange and signed five-plus-three reveal keep
20 physical counters. Paired predicates still require both natives;
printed ruling and complete-mode/protected deployment gates remain.
## Standalone E3 Nexus without Skills — 6 October 2026
Fresh original Ecaz OR Moritani/classic Nexus now retains Homeworlds with
+23
View File
@@ -1,5 +1,28 @@
# Dune implementation status
## 7 October 2026 — Single-native E1/E2 Nexus
Original one-native Ixians OR Tleilaxu/Ix47, or CHOAM OR Richese/CHOAM35
plus native cache, composes Nexus with or without Skills and optional
Homeworlds/Discovery/Tech3+/Advanced Strongholds2+. Existing original
initializers and source-local consumers remain; paired predicates still
require both natives.192 admissions reach setup versus eight before.
New15 programmes/affected398 across35 files and types/lint pass.
All32 original six-seat families yield190/192 completed208,205accepted/
208,213attempted/5,539JSON across two frozen trees: one real shared
ordinary-skill omission is repaired and its original Smuggler capture
finishes40/40/one JSON; two printed ruling guards remain preserved.
The obsolete solo-Ix rejection is deleted, not re-pinned. New physical
Smuggler5 plus ordinary Collection1 pays one six-spice pile exactly once.
Actual original390px Ixian offer/training/typed entry/Cunning/free support/
equal nested Cyborg substitution and no-training Richese one-spice5+3/
concealed reveal preserve20 counters in4CR7JHULv160/LNHZXDEEv102.
[Canonical source/game/human qualifications](NEXUS_CARD_RULES.md#single-native-e1e2-nexus-composition--7-october-2026).
Final types/lint/build and actual compiled390px guide pass; only the owned
QA worker reloads, retainingv160/v102 and physical pools. Exact pushed-revision
publication receipt remains pending; no full-mode, protected deployment
or comprehensive-assurance claim.
## 6 October 2026 — Standalone E3 Nexus without Skills
Original Ecaz OR Moritani/classic ecaz33/all12 Nexus now composes optional
+19
View File
@@ -5,6 +5,25 @@ 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.
## Single-native E1/E2 Nexus composition — 7 October 2026
Original `leader-skills --nexus-cards` now admits exactly one native Ixians
OR Tleilaxu OR CHOAM OR Richese with classics and the required one-family
deck, all14 Skills/all12 Nexus, optional Homeworlds/Discovery7/8/Tech3+
and Advanced Strongholds2+. Original Ix offers, ordinary-disc training,
Auditor exclusion, physical rescue/substitution and cleanup/Tech/Face
Dance remain. Optional revived own-disc training accepts the original
two-card draw before selection; foreign revival grants no new training.
[Canonical current programme evidence and retained boundaries](NEXUS_CARD_RULES.md#single-native-e1e2-nexus-composition--7-october-2026).
Fifteen original programmes/398 affected checks and source-qualified
190/192 completed games retain two printed ruling captures. The actual
bare-Nexus ordinary-skill omission is repaired through one shared term;
its original Smuggler capture finishes40/40 actions. Original bare
Smuggler5 plus Collection1 pays one physical six-spice pile exactly once.
Human C’tair's offered Planetologist remains alive/trained after free
Suboid support and equal nested Cyborg exchange. Comprehensive assurance,
full AI, public starts and complete-mode acceptance remain open.
## Native Homeworld Nexus composition — 6 October 2026
Original paired Ixians+Tleilaxu OR CHOAM+Richese and standalone Ecaz OR
+101
View File
@@ -2,6 +2,107 @@
Original source audit, 10 September 2026, with dated bounded runtime follow-ups below. This document establishes the printed component inventory, source protocol and implementation boundaries; none lifts expansion release gates. The ordinary worm-triggered alliance Nexus already in the engine is distinct from this optional card module.
## Single-native E1/E2 Nexus composition — 7 October 2026
The original private `nexus` and `leader-skills --nexus-cards` entries now
admit exactly **one** Ixians OR Tleilaxu with the Ix47 deck, or CHOAM OR
Richese with CHOAM35 and the native Richese cache when seated, plus classic
opponents. All12 Nexus remain; all14 Skills are present only when selected.
Basic/Advanced2–6 may retain original Homeworlds, Discovery7/8 through
`--discoveries`, Tech3+ and Advanced Strongholds2+. Selecting both industry
and Strongholds requires Advanced3+.
Original setup is reused: Ixian first offers/HMS, physical Tleilaxu Face
Dancers, CHOAM Auditor exclusion and Richese markers/cache retain their
native inventories. The paired entry still requires both native factions;
no parallel initializer, shipment/return adapter or played-save migration
is added. Mixed-deck Nexus, E3 pairs, public starts and unresolved printed
effects remain separate.
Original programmes cover typed native/visitor/nested Suk, residual Cyborg
substitution after Suboid Cunning, cleanup/winner Tech before Face Dance,
source-local Tleilaxu refresh and optional own-disc revival training,
Special/Shield CHOAM fuel and delayed paid support, and Basic/Advanced
signed Richese pairs with one invoice and actual materialization. Native
CHOAM Worthless-only effects and unrelated Semuta rules are not weakened.
Actual revival training first accepts the original optional two-card draw;
an empty offer before that acceptance is not a dealt hand.
Development evidence: **192 original fresh admissions**, versus eight
before this composition; **15 meaningful programmes**, all four minimal
policies and **398/398 affected checks across35 files**, types/lint pass.
The obsolete solo-Ix admission rejection is deleted, not re-pinned.
No complete-mode, deployed, recovery or calibration claim.
The32 original runner families use `single-nexus`,
`single-discovery-nexus`, `single-homeworld-nexus` and
`single-homeworld-nexus-discovery`, each optionally `-skills`, then
`-tech`, `-stronghold` or `-stronghold-tech`. Stable14200–17300 bands leave
older names/seeds unchanged. Each scenario name includes the actual native
roster to avoid collisions between the four original setups.
All32 original six-seat families at seed20270007 retain unchanged tree
`dbc4efcd95aa185fb14b3ab73a05d0939896bb3f105cc4ec218957ccbe2a0c99`
on HEAD204d919:189 of192 finish206,487 accepted/206,493 attempted actions,
5,493 JSON continuations. Every before/after source receipt was read.
The two source-bound exhausted Richese cache/low-Tupile CHOAM captures
remain preserved. The third capture exposed an actual ordinary-skill
consumer omission: without HW/Discovery/Tech/Strongholds, the new single
Nexus predicate was absent from `ordinaryLeaderSkillModeSupported`.
All four policies had no plan for the sole available Smuggler-trained
leader. One shared original predicate term repairs it without changing
Smuggler rules or adding an AI exception. The exact original capture
finishes40/40 actions/one JSON on unchanged
`cc4fab886b6169faf7ddeb9004ed11c01617a0587be65f8939b3f0e8e4fcaa52`.
Combined190/192 completion accounts for the retained1678/1680/45 prefix
and its continuation:208,205 accepted/208,213 attempted/5,539 JSON across
**two** frozen trees, not192 uninterrupted passes or final-tree assurance.
The added original bare-Skills programme proves printed Smuggler5 then
ordinary Collection1 from one physical six-spice pile, paid exactly once.
Original human room **4CR7JHUL v160**: HTTP Ix/Guild/Fremen ready lobby,
original private Skills/Nexus/Discovery entry and actual La La La offer;
human chooses the genuinely offered Planetologist on C’tair. Native
2/1 Gara8 shipment pays4 spice, actual random Cistern inspection/reveal
and next-turn free1 Suboid/1 Cyborg entry preserve20 physical/seven Cyborgs.
Real Maker majority/typed Fremen2/1 ride, both Advanced piles and settled
Guild/Fremen alliance precede the human Ixian closing draw. Human Cunning
spends one singleton atv150; actual normal1/full/free quote, C’tair5/dial1/
support0 seal, normal battle and chosen Cyborg1 loss lead to human equal
1 Suboid/1 Cyborg nested substitution. Purse7 stays unchanged through
exchange; later original Cistern Collection2 is separate. Final12 native,
6 HMS,1 Cistern Cyborg and1 normal Tanks total20; Cyborgs3native+3HMS+
1Cistern, none in Tanks. C’tair remains alive/trained; discard exactly[Ixians].
Original no-training room **LNHZXDEE v102**: fresh Richese/Guild/Fremen
HTTP/private Nexus/Discovery setup, native20/cache10 and actual Haloa Rund
traitor choice. Original paid Guild3 parent/free entry and random Cistern,
real Maker majority/both piles/alliance and closing draw precede native
Cunning. Human five-plus-three nested pair quotes **one** spice: atv101,
purse5→4, native20→15, Cistern5 plus concealed3; human reveal atv102
materializes exactly3 more, native12/Cistern8/Tanks0, no marker or extra
fee. No absent CHOAM native income is fabricated; discard exactly[Richese].
Actual controls, fresh AX/screens and both20-counter public inspectors
fit viewport/document390. Only conserved unplayed order and undealt
closing singleton were selected by exact owned-room CAS; no cards dealt,
forces, wallets, deaths, phases or battle outcomes were staged.
Actual CLI rejects two-seat single Skills/Tech and Basic single HW/Nexus/
Discovery/Strongholds with exit1 **before** either output directory exists.
Source-qualified reports and credential-free human evidence remain in
`/tmp/dune-single-native-nexus-human-proof-20261007.json`, outside Git.
Final types/lint/build pass. The final compiled Nexus scope/398-check
evidence renders at viewport/document390 after reloading only the owned
isolated QA worker; both changed rooms retainv160/v102 and physical pools.
Exact pushed-revision publication receipts remain pending; protected
deployment stays separately blocked.
Canonical implementation: `game/leader-skill-profile.ts`,
`game/nexus-module-profile.ts`, `game/discovery-module-profile.ts` and the
existing `game/engine.ts` initializers; original programme evidence:
`tests/single-nexus-e1-runtime.test.ts` and
`tests/single-nexus-e2-runtime.test.ts`.
## Standalone E3 Nexus without Skills — 6 October 2026
The existing original `nexus` private entry now admits Ecaz **OR** Moritani
+15
View File
@@ -2,6 +2,21 @@
Updated 2 October 2026. This is the authoritative navigation index for existing decisions, not a replacement rulebook or a new ruling. Detailed linked contracts retain their source citations and exact supported boundaries. Runtime follow-ups supersede historical absence claims; passing tests establish software behavior, not publisher authority.
## Single-native E1/E2 Nexus composition — 7 October 2026
The same authorized physical PDF21–23 optional-module independence admits
exactly one original Ixians OR Tleilaxu, or CHOAM OR Richese, with classic
opponents and its required one-family deck. Original Nexus/Skills setup
now composes optional Homeworlds/Discovery/Tech/Advanced Strongholds;
the paired entry still requires both natives. This removes an admission
limitation, not a printed effect guard. Native-only own-disc revival
training first accepts its optional draw; native CHOAM Worthless effects
are distinct from Nexus any-Treachery fuel.
[Canonical scope and current consumer evidence](NEXUS_CARD_RULES.md#single-native-e1e2-nexus-composition--7-october-2026).
Mixed decks, native free-return/companion, public-start/save conversion and
pending printed rulings remain separate. Software passes confer no new
source authority or complete-mode certification.
## Standalone E3 Nexus without Skills — 6 October 2026
Revision physical PDF21–23 permits original optional modules together/
+27 -1
View File
@@ -16,7 +16,7 @@ export function homeworldDiscoveryProfile(game: FactionModuleProfile): boolean {
/** The same roster/module contract during play; live Discovery frames are expected. */
export function discoveryModeSupported(game: FactionModuleProfile): boolean {
if (game.nexusCards) return standaloneE3DiscoveryNexusProfile(game);
if (game.nexusCards) return standaloneE3DiscoveryNexusProfile(game) || singleE1E2DiscoveryNexusProfile(game);
if (typeof game.advanced !== 'boolean' || game.discoveryEnabled !== true ||
game.players.length < 2 || game.players.length > 6 ||
(game.techTokens && game.players.length < 3) || game.expansions.length > 2 ||
@@ -155,3 +155,29 @@ export function standaloneE3DiscoveryNexusProfile(game: FactionModuleProfile): b
}
return native !== null && !(game.advanced && native === 'moritani' && harkonnen);
}
/** Exactly one original E1/E2 native/classics with Nexus/Discovery and no Skills. */
export function singleE1E2DiscoveryNexusProfile(game: FactionModuleProfile): boolean {
if (typeof game.advanced !== 'boolean' || game.discoveryEnabled !== true ||
!game.nexusCards || game.expansions.length !== 1 ||
game.players.length < 2 || game.players.length > 6 ||
(game.techTokens && game.players.length < 3) ||
(game.strongholdCards && !game.advanced) || game.leaderSkills ||
game.ecazTreachery || game.semutaPreview || game.advancedPreview || game.kullPreview ||
game.nexusKullPreview || game.guildBetrayalPreview || game.richeseBetrayalPreview ||
game.nexusIxianReplacementPreview || game.nexusIxianBetrayalPreview ||
game.nexusHarkonnenBetrayalPreview) return false;
const expansion = game.expansions[0];
if (expansion !== 'ix' && expansion !== 'choam') return false;
let native = 0;
for (let i = 0; i < game.players.length; i++) {
const player = game.players[i];
for (let j = 0; j < i; j++) if (player.faction === game.players[j].faction) return false;
let family: string | undefined;
for (const faction of FACTIONS)
if (faction.id === player.faction) { family = faction.expansion; break; }
if (family !== 'base' && family !== expansion) return false;
if (family === expansion) native++;
}
return native === 1;
}
+21 -14
View File
@@ -1,7 +1,7 @@
import { classicNexusModulesProfile, pairedNexusModulesProfile, standaloneE3NexusProfile } from './nexus-module-profile';
import { classicNexusModulesProfile, pairedNexusModulesProfile, singleE1E2NexusProfile, standaloneE3NexusProfile } from './nexus-module-profile';
import { nativeFactionTechProfile } from './faction-module-profile';
import { classicDiscoveryLeaderSkillsProfile, classicDiscoveryNexusProfile, homeworldDiscoveryProfile, discoveryModeSupported, discoveryModuleProfile, pairedDiscoveryNexusProfile, standaloneE3DiscoveryNexusProfile } from './discovery-module-profile';
import { advancedMoritaniLeaderSkillsProfile, advancedNativeLeaderSkillsProfile, homeworldLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, pairedNexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile } from './leader-skill-profile';
import { classicDiscoveryLeaderSkillsProfile, classicDiscoveryNexusProfile, homeworldDiscoveryProfile, discoveryModeSupported, discoveryModuleProfile, pairedDiscoveryNexusProfile, singleE1E2DiscoveryNexusProfile, standaloneE3DiscoveryNexusProfile } from './discovery-module-profile';
import { advancedMoritaniLeaderSkillsProfile, advancedNativeLeaderSkillsProfile, homeworldLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, pairedNexusLeaderSkillsProfile, singleE1E2NexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile } from './leader-skill-profile';
import { bribeTimingBlock, maximumBribe, type BribeOptions } from './bribe-options';
import { quoteSpicePlacement, stormExposesTerritory, stormSectorAfter, wormConsumesForces } from './disaster-rules';
import { isStormCardDistance, type StormCardComponent } from './storm-cards';
@@ -8573,14 +8573,17 @@ 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.');
const standalone = standaloneE3NexusProfile(state);
const single = singleE1E2NexusProfile(state);
if (state.discoveryEnabled) {
requireRule(classicDiscoveryNexusProfile(state) || standaloneE3DiscoveryNexusProfile(state),
'Discovery Nexus requires a fresh classic or standalone E3 lobby with optional original Homeworlds, Tech and Advanced Strongholds.');
requireRule(classicDiscoveryNexusProfile(state) || standaloneE3DiscoveryNexusProfile(state) ||
singleE1E2DiscoveryNexusProfile(state),
'Discovery Nexus requires a fresh classic, single E1/E2 native or standalone E3 lobby with original optional modules.');
requireFreshBaseRuntime(state);
requireFreshFactionInventory(state);
}
const g = initializeSetupGameForAudit(state, !!state.homeworlds, true, false,
classicDiscoveryNexusProfile(state) || standaloneE3DiscoveryNexusProfile(state), false, false, standalone);
classicDiscoveryNexusProfile(state) || standaloneE3DiscoveryNexusProfile(state) || singleE1E2DiscoveryNexusProfile(state),
false, false, standalone || single);
if (standalone && byFaction(g, 'ecaz')) g.ecazOccupyPreview = true;
if (standalone && g.advanced && byFaction(g, 'moritani')) initializeMoritaniAssassinateState(g);
return g;
@@ -9551,7 +9554,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, paired E1/E2 or standalone E3 lobby with original optional modules and undealt native custody.');
'Homeworld Leader Skills require a fresh supported classic or native E1/E2/standalone E3 lobby with original modules and undealt custody.');
requireFreshBaseRuntime(state);
requireFreshFactionInventory(state);
}
@@ -9563,7 +9566,9 @@ export function initializeLeaderSkillsGameForAudit(state: Game): Game {
const g = structuredClone(state);
g.leaderSkills = createLeaderSkills(random);
const classicNexus = classicNexusLeaderSkillsProfile(g);
const initialized = initializeSetupGameForAudit(g, homeworlds, classicNexus || pairedNexusLeaderSkillsProfile(g) || standaloneE3NexusLeaderSkillsProfile(g), false, classicDiscoveryLeaderSkillsProfile(g) || nativeDiscoveryLeaderSkillsProfile(g) || (g.discoveryEnabled === true && classicNexus), true,
const initialized = initializeSetupGameForAudit(g, homeworlds, classicNexus || pairedNexusLeaderSkillsProfile(g) ||
singleE1E2NexusLeaderSkillsProfile(g) || standaloneE3NexusLeaderSkillsProfile(g), false,
classicDiscoveryLeaderSkillsProfile(g) || nativeDiscoveryLeaderSkillsProfile(g) || (g.discoveryEnabled === true && classicNexus), true,
g.expansions.length === 1 && g.expansions[0] === 'choam', nativeExpansionLeaderSkillsProfile(g));
if (advancedMoritaniLeaderSkillsProfile(initialized)) initializeMoritaniAssassinateState(initialized);
if ((homeworlds || nativeDiscoveryLeaderSkillsProfile(initialized)) && byFaction(initialized, 'ecaz')) initialized.ecazOccupyPreview = true;
@@ -9607,8 +9612,10 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
const discoveryComposition = discovery && (discoveryModuleProfile(g) ||
classicDiscoveryNexusProfile(g) || pairedDiscoveryNexusProfile(g) ||
(leaderSkills && (classicDiscoveryLeaderSkillsProfile(g) || nativeDiscoveryLeaderSkillsProfile(g) || classicNexusLeaderSkillsProfile(g))));
const nexusModules = nexus && (classicNexusModulesProfile(g) || pairedNexusModulesProfile(g) || standaloneE3NexusProfile(g) ||
(leaderSkills && (classicNexusLeaderSkillsProfile(g) || pairedNexusLeaderSkillsProfile(g) || standaloneE3NexusLeaderSkillsProfile(g))));
const nexusModules = nexus && (classicNexusModulesProfile(g) || pairedNexusModulesProfile(g) ||
singleE1E2NexusProfile(g) || standaloneE3NexusProfile(g) ||
(leaderSkills && (classicNexusLeaderSkillsProfile(g) || pairedNexusLeaderSkillsProfile(g) ||
singleE1E2NexusLeaderSkillsProfile(g) || standaloneE3NexusLeaderSkillsProfile(g))));
requireRule(!g.techTokens || ((skillTech || nativeTech || nexusModules || discoveryComposition) &&
JSON.stringify(g.techTokens) === JSON.stringify(createTechTokens())),
'Tech Tokens require a fresh supported three-through-six-seat lobby with unused tokens.');
@@ -9634,7 +9641,7 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
strongholdFactions
? 'The Stronghold faction audit needs a fresh supported Advanced native lobby without other optional modules.'
: factions && nexus
? 'The paired expansion Nexus audit needs a fresh lobby without other optional modules.'
? 'The native expansion Nexus audit needs a fresh supported original roster and module envelope.'
: factions
? 'The faction prototype supports base factions and the selected expansion factions without optional modules.'
: choam
@@ -25752,9 +25759,9 @@ const RICHESE_CUNNING_ROSTER: Partial<Record<FactionId, true>> = {
beneGesserit: true, guild: true, richese: true, choam: true,
};
function richeseCunningModeSupported(g: Game) {
const skills = !!g.leaderSkills && pairedNexusLeaderSkillsProfile(g);
return pairedDiscoveryNexusProfile(g) ||
(!!g.homeworlds && (skills || pairedNexusModulesProfile(g))) ||
const skills = !!g.leaderSkills && (pairedNexusLeaderSkillsProfile(g) || singleE1E2NexusLeaderSkillsProfile(g));
return pairedDiscoveryNexusProfile(g) || singleE1E2DiscoveryNexusProfile(g) ||
(!!g.homeworlds && (skills || pairedNexusModulesProfile(g) || singleE1E2NexusProfile(g))) ||
(!g.homeworlds && (!g.leaderSkills || skills) && (!g.discoveryEnabled || skills) &&
!g.ecazTreachery && !g.sandtrout &&
g.players.every(player => RICHESE_CUNNING_ROSTER[player.faction] === true));
+23 -2
View File
@@ -85,6 +85,24 @@ export function pairedNexusLeaderSkillsProfile(game: LeaderSkillProfile): boolea
return hasPrimary && hasSecondary;
}
/** Exactly one original E1/E2 native with classics; paired/mixed rosters stay separate. */
export function singleE1E2NexusLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
if (game.expansions.length !== 1 || !nexusLeaderSkillModulesSupported(game, false, true)) return false;
const expansion = game.expansions[0];
if (expansion !== 'ix' && expansion !== 'choam') return false;
const players = game.players!;
let native = 0;
for (let i = 0; i < players.length; i++) {
for (let j = 0; j < i; j++) if (players[j].faction === players[i].faction) return false;
let family: string | undefined;
for (const faction of FACTIONS)
if (faction.id === players[i].faction) { family = faction.expansion; break; }
if (family !== 'base' && family !== expansion) return false;
if (family === expansion) native++;
}
return native === 1;
}
/** One original E3 native with classics and optional Homeworlds/Discovery; no shared Duke or mixed family. */
export function standaloneE3NexusLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
if (game.expansions.length !== 1 || game.expansions[0] !== 'ecaz' ||
@@ -131,7 +149,8 @@ function discoveryLeaderSkillModulesSupported(game: LeaderSkillProfile): boolean
/** E1/E2 native skills may add Tech and Advanced Strongholds; original roster/decks remain. */
function nativeLeaderSkillModulesSupported(game: LeaderSkillProfile): boolean {
if (game.homeworlds) return homeworldLeaderSkillModulesSupported(game);
if (noOtherLeaderSkillModules(game) || pairedNexusLeaderSkillsProfile(game)) return true;
if (noOtherLeaderSkillModules(game) || pairedNexusLeaderSkillsProfile(game) ||
singleE1E2NexusLeaderSkillsProfile(game)) return true;
if (game.discoveryEnabled || game.discoveries) return discoveryLeaderSkillModulesSupported(game);
const players = game.players;
if ((!game.techTokens && !game.strongholdCards) || !players ||
@@ -385,7 +404,8 @@ function homeworldLeaderSkillModulesSupported(game: LeaderSkillProfile): boolean
if (!game.homeworlds || typeof game.advanced !== 'boolean' ||
!players || players.length < 2 || players.length > 6 ||
(game.nexusCards && !classicNexusLeaderSkillsProfile(game) &&
!pairedNexusLeaderSkillsProfile(game) && !standaloneE3NexusLeaderSkillsProfile(game)) ||
!pairedNexusLeaderSkillsProfile(game) && !singleE1E2NexusLeaderSkillsProfile(game) &&
!standaloneE3NexusLeaderSkillsProfile(game)) ||
(game.discoveryEnabled !== true && (game.discoveryEnabled || game.discoveries || game.discoveryStash || game.greatMaker)) ||
(game.techTokens && players.length < 3) || (game.strongholdCards && !game.advanced) ||
game.ecazTreachery ||
@@ -421,6 +441,7 @@ export function ordinaryLeaderSkillModeSupported(game: LeaderSkillProfile): bool
(!game.expansions.length || nativeExpansionLeaderSkillsProfile(game))) ||
classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game) ||
strongholdLeaderSkillsProfile(game) || classicNexusLeaderSkillsProfile(game) ||
singleE1E2NexusLeaderSkillsProfile(game) ||
pairedNexusLeaderSkillsProfile(game) || standaloneE3NexusLeaderSkillsProfile(game) ||
classicDiscoveryLeaderSkillsProfile(game) || nativeDiscoveryLeaderSkillsProfile(game) ||
homeworldLeaderSkillsProfile(game);
+28 -1
View File
@@ -1,6 +1,6 @@
import { FACTIONS } from './catalog';
import type { FactionModuleProfile } from './faction-module-profile';
import { classicDiscoveryNexusProfile, pairedDiscoveryNexusProfile, standaloneE3DiscoveryNexusProfile } from './discovery-module-profile';
import { classicDiscoveryNexusProfile, pairedDiscoveryNexusProfile, singleE1E2DiscoveryNexusProfile, standaloneE3DiscoveryNexusProfile } from './discovery-module-profile';
/** Original classic Nexus modules, optionally Homeworlds; initializer checks undealt components. */
export function classicNexusModulesProfile(game: FactionModuleProfile): boolean {
@@ -88,3 +88,30 @@ export function standaloneE3NexusProfile(game: FactionModuleProfile): boolean {
}
return native !== null && !(game.advanced && native === 'moritani' && harkonnen);
}
/** Exactly one original E1/E2 native/classics without Skills, with optional original modules. */
export function singleE1E2NexusProfile(game: FactionModuleProfile): boolean {
if (game.discoveryEnabled || game.discoveries) return singleE1E2DiscoveryNexusProfile(game);
if (typeof game.advanced !== 'boolean' || !game.nexusCards ||
game.expansions.length !== 1 || game.players.length < 2 || game.players.length > 6 ||
(game.techTokens && game.players.length < 3) ||
(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 ||
game.richeseBetrayalPreview || game.nexusIxianReplacementPreview ||
game.nexusIxianBetrayalPreview || game.nexusHarkonnenBetrayalPreview) return false;
const expansion = game.expansions[0];
if (expansion !== 'ix' && expansion !== 'choam') return false;
let native = 0;
for (let i = 0; i < game.players.length; i++) {
const player = game.players[i];
for (let j = 0; j < i; j++) if (player.faction === game.players[j].faction) return false;
let family: string | undefined;
for (const faction of FACTIONS)
if (faction.id === player.faction) { family = faction.expansion; break; }
if (family !== 'base' && family !== expansion) return false;
if (family === expansion) native++;
}
return native === 1;
}
+14 -11
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. Optional original Tech requires three or more seats; Stronghold Cards require Advanced. Classic, paired Ixians+Tleilaxu/CHOAM+Richese or standalone Ecaz OR Moritani entries may retain all12 Nexus through the existing skill or Nexus entry, including no-Skills standalone E3. Original family decks and each roster/effect restriction remain: Advanced Moritani excludes Harkonnen; an otherwise legal no-Skills Ecaz/Harkonnen roster does not lift the separate Duke Cunning exclusion. 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. Other native singles/mixed-deck 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. Optional original Tech requires three or more seats; Stronghold Cards require Advanced. Classic, single or paired Ixians/Tleilaxu or CHOAM/Richese with one family deck, or standalone Ecaz OR Moritani entries may retain all12 Nexus through the existing skill or Nexus entry, with or without Skills. Original family decks and each roster/effect restriction remain: Advanced Moritani excludes Harkonnen; an otherwise legal no-Skills Ecaz/Harkonnen roster does not lift the separate Duke Cunning exclusion. 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. Mixed-deck 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,8 +3984,10 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Verification',
status: 'Partial',
detail:
'Standalone E3 without Skills adds15 meaningful cases; affected372/372 across32 files, types/lint/build pass. Across16 families,46 of48 original six-seat games finish44796 accepted/45084 attempted actions with1189 JSON across two frozen source trees. Eight initial new-name collisions are preserved and repaired without changing seeds; Basic odd-force and high-threshold revival captures retain their ruling guards. Actual390px no-training Cistern/native invasion/Duke Cunning and supply-only Moritani Cunning/real paid Terror arrival retain original card/token/leader/counter costs. Earlier skill/native/classic evidence remains canonical. Not complete rules, all combinations, comprehensive assurance/calibration or deployed acceptance.',
'Single-native E1/E2 Nexus adds15 meaningful programmes; affected398/398 across35 files and types/lint pass. All32 original six-seat families finish190/192 games with208205 accepted/208213 attempted actions and5539 JSON across two frozen trees, including one repaired original Smuggler continuation. Exhausted-cache/low-Tupile captures retain source guards. Actual390px original trained Ixian offer/typed Cistern entry, Maker/both-pile draw, free Suboid support and equal nested Cyborg exchange preserve20 physical/seven Cyborgs; no-training Richese five-plus-three pair/reveal costs one spice and preserves20. Earlier classic/native/E3 evidence remains canonical. Full rules, combinations, assurance/calibration and deployed acceptance remain open.',
evidence: [
'tests/single-nexus-e1-runtime.test.ts',
'tests/single-nexus-e2-runtime.test.ts',
'tests/homeworld-classic-skills-runtime.test.ts',
'tests/homeworld-classic-discovery-runtime.test.ts',
'tests/homeworld-skills-discovery-runtime.test.ts',
@@ -4093,10 +4095,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 composes all fourteen Skills. Classic, paired Ixians+Tleilaxu/CHOAM+Richese or standalone Ecaz OR Moritani entries retain all12 Nexus through the original Nexus entry without Skills, or their existing 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. Other native singles/mixed-deck 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. Original Homeworlds may be retained; --nexus-cards requires classic factions, paired Ixians+Tleilaxu OR CHOAM+Richese with one family deck, or standalone Ecaz OR Moritani with classics. All12 Nexus retain 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. Other native singles/mixed decks, 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, optionally retaining original Homeworlds: 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, whose foreign-Homeworld suppression remains. E2 keeps the signed two-marker frame and one invoice. Ecaz keeps quiet living/unclaimed Duke separate from training and a real owner-labelled Arrakis Occupy; Moritani keeps skill-first assassination and one private Mentat replacement. Mixed/E3 pairs, shared Duke, borrowed Smuggler/pair companions, native free returns, captured/foreign training, Basic odd-force Ecaz and Advanced Harkonnen stay separate.',
'A separate fresh Nexus entry may explicitly add Discoveries to classic factions, both native Ixians/Tleilaxu OR CHOAM/Richese with classics, or standalone Ecaz OR Moritani with classics and one original ecaz deck, without Skills and optionally retaining original Homeworlds. Original Tech requires three or more seats; Stronghold Cards require Advanced. Native Ecaz Arrakis Occupy and Advanced Moritani assassination retain their original source/effect exclusions; no training component or starting Nexus deal is added. 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 ledgers, Guild-priced nested shipment and signed native one-invoice marker pairs retain physical pieces and original capacities. E3pairs/mixed families, unrelated previews, unresolved native free returns and mixed-module Fremen Betrayal remain separate.',
'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 composes all fourteen Skills. Classic, single or paired Ixians/Tleilaxu or CHOAM/Richese with one family deck, or standalone Ecaz OR Moritani entries retain all12 Nexus through the original Nexus entry without Skills, or their existing 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. Mixed-deck 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. Original Homeworlds may be retained; --nexus-cards requires classic factions, single or paired Ixians/Tleilaxu OR CHOAM/Richese with one family deck, or standalone Ecaz OR Moritani with classics. All12 Nexus retain 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. Mixed decks, 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 single or paired Ixians/Tleilaxu, single or paired CHOAM/Richese or standalone Ecaz OR Moritani with classics, optionally retaining original Homeworlds: 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, whose foreign-Homeworld suppression remains. E2 keeps the signed two-marker frame and one invoice. Ecaz keeps quiet living/unclaimed Duke separate from training and a real owner-labelled Arrakis Occupy; Moritani keeps skill-first assassination and one private Mentat replacement. Mixed/E3 pairs, shared Duke, borrowed Smuggler/pair companions, native free returns, captured/foreign training, Basic odd-force Ecaz and Advanced Harkonnen stay separate.',
'A separate fresh Nexus entry may explicitly add Discoveries to classic factions, single or paired native Ixians/Tleilaxu OR CHOAM/Richese with classics and one family deck, or standalone Ecaz OR Moritani with classics and one original ecaz deck, without Skills and optionally retaining original Homeworlds. Original Tech requires three or more seats; Stronghold Cards require Advanced. Native Ecaz Arrakis Occupy and Advanced Moritani assassination retain their original source/effect exclusions; no training component or starting Nexus deal is added. 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 ledgers, Guild-priced nested shipment and signed native one-invoice marker pairs retain physical pieces and original capacities. E3pairs/mixed families, unrelated previews, unresolved native free returns and mixed-module Fremen Betrayal remain separate.',
'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.',
@@ -4136,8 +4138,9 @@ export const RULE_TOPICS: RuleTopic[] = [
},
{
area: 'Verification', status: 'Partial',
detail: 'Standalone E3 without Skills adds15 meaningful cases; affected372/372 across32 files, types/lint/build pass. Across16 families,46 of48 original six-seat games finish44796/45084 actions with1189 JSON across two frozen trees after eight new-name collisions are repaired with unchanged seeds. Two original source guards remain. Actual390px original no-training Ecaz random Cistern reveal/free entry, Maker/both-pile closing draw, native invasion and living-Duke Cunning retain20 counters; Moritani supply Cunning and paid arrival reveal consume one card/token and kill one random actual Guild disc for its printed bounty. Earlier source-qualified evidence remains canonical; full rules/combinations and deployed acceptance remain open.',
detail: 'Single-native E1/E2 Nexus adds15 original programmes and398/398 affected checks across35 files, types/lint pass. Across32 families,190/192 genuine six-seat games finish208205/208213 actions with5539 JSON across two frozen trees after one real ordinary-skill consumer omission is repaired through the shared predicate. Exhausted-cache/low-Tupile captures remain guarded. Actual390px typed native Cistern entry, Maker/both-pile alliance closing, human Suboid Cunning/equal exchange and native one-invoice five-plus-three reveal preserve original20-counter budgets. Earlier source-qualified evidence remains canonical; full rules/combinations and deployed acceptance remain open.',
evidence: ['tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts',
'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts',
'tests/homeworld-paired-nexus-runtime.test.ts', 'tests/homeworld-standalone-nexus-runtime.test.ts',
'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'],
},
@@ -4420,10 +4423,10 @@ export const RULE_TOPICS: RuleTopic[] = [
'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 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 modules, optionally Discovery. Original paired E1/E2 and standalone E3 entries retain their own documented native roster/deck/effect boundaries. Unrelated previews, public starts and saved-game conversion remain separate.',
'Fresh nexus --discoveries preserves original Tech and Advanced Stronghold prerequisites with classic factions, both native Ixians/Tleilaxu OR CHOAM/Richese with one family deck, or standalone Ecaz OR Moritani with classics and ecaz33, without Skills. These entries may 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’s completed losses do not repeat. Source-clear quotas, borrowed Guild tariffs and signed native two-marker one-invoice shipments into currently revealed sites use original controls. E3pairs/mixed families, unresolved native free-return and mixed-module Fremen Betrayal or other unfinished effects remain guarded; no public start or save conversion.',
'The 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, optionally original Homeworlds. Discovery may 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/No-Fields, original payments/rewards and held effects remain. Other native single/mixed families, unrelated previews, public starts and unresolved rulings remain separate.',
'Fresh nexus --discoveries preserves original Tech and Advanced Stronghold prerequisites with classic factions, single or paired native Ixians/Tleilaxu OR CHOAM/Richese with one family deck, or standalone Ecaz OR Moritani with classics and ecaz33, without Skills. These entries may 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’s completed losses do not repeat. Source-clear quotas, borrowed Guild tariffs and signed native two-marker one-invoice shipments into currently revealed sites use original controls. E3pairs/mixed families, unresolved native free-return and mixed-module Fremen Betrayal or other unfinished effects remain guarded; no public start or save conversion.',
'The fresh local Nexus entry also composes Tech and/or Advanced Strongholds with single or paired Ixians/Tleilaxu OR CHOAM/Richese and classic opponents, one original family deck and no Skills, optionally original Homeworlds. Discovery may 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/No-Fields, original payments/rewards and held effects remain. The paired initializer still requires both native factions; single-native games use the existing generic initializer and original faction setup. Mixed families, unrelated previews, public starts and unresolved rulings remain separate.',
'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.',
'The same fresh skill/Nexus entry supports single or paired Ixians/Tleilaxu OR 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. Original all14 Auditor exclusion and native-only optional own-disc revival training remain. Mixed families, E3, companions/mixed plans, native free-return accounting and pending rulings stay guarded.',
'A separate standalone Ecaz OR Moritani skill/Nexus entry uses exact ecaz33/all14/all12 with classics, Basic/Advanced two through six seats, optionally original Homeworlds and Discovery; original Tech requires3+, Strongholds requires Advanced. Actual closing draws, five ordinary training discs, quiet living/unclaimed Duke Cunning and original Advanced assassination/normal-Traitor forfeiture remain. Native support/rescue, held effects, original card cleanup and mandatory winner Tech use existing handlers. Advanced Harkonnen, E3 pairs/mixed families/previews, Duke training/exceptional custody, enhanced Terror relocation/HMS/Grumman and other pending rulings stay guarded.',
'The original nexus entry also admits standalone Ecaz OR Moritani with classics, ecaz33/all12 and NO Skills, Basic/Advanced2–6, optionally Homeworlds, Discovery7/8, Tech3+ and Advanced Strongholds2+. Original Traitors/native setup precede a qualifying closing draw, with no starting Nexus hand. Quiet Duke Cunning stays separate from shared-disc training; no-Skills Ecaz may face Advanced Harkonnen without lifting the Cunning exclusion. Advanced Moritani excludes Harkonnen, retains post-loss private assassination/normal-call forfeiture and Mentat replacement, and uses supply-only Terror Cunning. Original Ecaz-selected coalition losses/payments and source-local held benefits/Tech remain. A sole loser token transfers automatically after cleanup; a real multi-token choice remains mandatory. Other native/mixed families, borrowed free-return/companion and relocation rulings, public starts and save conversion remain separate.',
'Atreides Cunning adds a second, different opposing plan element after the first native answer. Both remain binding. Use it in your own battle before sealing your plan; Karama may cancel the extra attempt while preserving the first disclosure.',
@@ -4476,7 +4479,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: 'Standalone E3 without Skills adds15 semantic cases; affected372/372 across32 files, types/lint/build pass. Across16 families,46 of48 original six-seat games finish44796/45084 actions with1189 JSON across two frozen trees after eight new trace-name collisions are repaired without changing seeds. Basic odd-force and high-threshold revival captures retain source guards. Actual390px original no-training Ecaz Cistern/free entry/Maker/closing draw/native invasion/Duke and Moritani supply Cunning/paid arrival/random assassination retain physical card/token/disc/counter costs. Earlier base/native/skill/private and phone evidence remains source-qualified, not a new assurance campaign or complete-mode/deployed certificate.', evidence: ['tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', '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', 'docs/NEXUS_CARD_RULES.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Single-native E1/E2 adds15 semantic programmes/398 affected checks across35 files, types/lint pass. All32 original six-seat families finish190/192 games with208205/208213 actions and5539 JSON across two frozen trees, including one repaired original bare-Skills Smuggler continuation. Exhausted-cache/low-Tupile source guards remain. Actual390px original trained Ixian offer/typed entry, Maker/both-pile draw, free Suboid support/equal nested Cyborg exchange and no-training Richese one-invoice5+3/reveal preserve20 physical counters. Earlier source-qualified evidence remains canonical, not new assurance or complete-mode/deployed certification.', evidence: ['tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', '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', 'docs/NEXUS_CARD_RULES.md'] },
],
},
{
+1 -4
View File
@@ -70,10 +70,7 @@ void test('Discovery Skills cannot replace already initialized Discovery compone
assert.throws(() => initializeLeaderSkillsGameForAudit(started), RuleError);
});
void test('Discovery Nexus rejects native families, used components and incompatible prerequisites before redealing', () => {
const native = lobby(['ixians', 'guild', 'fremen'], ['ix']);
native.nexusCards = { cards: null, phase: null };
assert.throws(() => initializeNexusGameForAudit(native), RuleError);
void test('Discovery Nexus rejects used components and incompatible prerequisites before redealing', () => {
const two = lobby(['guild', 'emperor'], []);
two.nexusCards = { cards: null, phase: null }; two.techTokens = createTechTokens();
assert.throws(() => initializeNexusGameForAudit(two), RuleError);
+373
View File
@@ -0,0 +1,373 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeLeaderSkillsGameForAudit, initializeNexusGameForAudit,
joinGame, newPlayer, viewGame,
} from '../game/engine';
import type { Action, Game } from '../game/engine';
import type { FactionId } from '../game/catalog';
import { territory } from '../game/board';
import { treacheryDeck } from '../game/cards';
import type { Card } from '../game/cards';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import type { LeaderSkillId } from '../game/leader-skill-cards';
import { validateLeaderSkills } from '../game/leader-skills';
import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards';
import { traitorDeck } from '../game/traitors';
import { nativeShipmentSources } from '../game/homeworld-options';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import { combatArmy, homeworldBattleLocation } from '../game/combat-location';
import { quoteBattleResolution } from '../game/battle-resolution-quote';
import type { BattleResolutionQuote, ResolutionCombatant } from '../game/battle-resolution-quote';
import { DISCOVERY_CARD_PLACEMENTS, validateDiscoveryState } from '../game/discoveries';
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
import { greatMakerRideAction } from '../game/great-maker-options';
import {
nativeHomeworldInventory, nativeHomeworldLeader, nativeHomeworldPlayer, frontNativeHomeworldSpice,
} from './fixture-homeworld-native-e1e2-modules';
import { nextHomeworldSkillsDiscoveryStep } from './fixture-homeworld-skills-discovery';
import { withClassicDiscoveryNexusLottery, withClassicDiscoveryNexusToken } from './fixture-discovery-classic-nexus';
export type SingleNexusE1Native = 'ixians' | 'tleilaxu';
export type SingleNexusE1Step = { actor: string; action: Action };
export type SingleNexusE1Transition = { before: Game; step: SingleNexusE1Step; after: Game };
export type SingleNexusE1Options = {
/** Only an original lobby or its unassigned turn-one setup; saved deals are authoritative. */
initial?: Game;
native?: SingleNexusE1Native;
roster?: readonly FactionId[];
advanced?: boolean;
skills?: boolean;
homeworlds?: boolean;
discovery?: boolean;
tech?: boolean;
strongholds?: boolean;
startingKinds?: readonly Card['kind'][];
collectorSkill?: LeaderSkillId;
};
export type SingleNexusE1Setup = {
initial: Game; setup: Game; offered: Game | null; ixOffer: SingleNexusE1Transition | null;
afterSetup: Game; owner: string; guild: string; fremen: string; collector: string;
trainer: string | null; dancerLeader: string; actions: SingleNexusE1Step[];
};
export type SingleNexusE1Programme = SingleNexusE1Setup & {
game: Game; firstArrival: SingleNexusE1Transition | null;
reveal: SingleNexusE1Transition | null; entry: SingleNexusE1Transition | null;
vote: Game | null; ride: SingleNexusE1Transition | null;
firstMentat: SingleNexusE1Transition; alliance: Game; drawing: Game; draw: SingleNexusE1Transition;
};
export const singleNexusE1Reload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game;
export const singleNexusE1Player = nativeHomeworldPlayer;
export const singleNexusE1Army = combatArmy;
export const singleNexusE1Leader = nativeHomeworldLeader;
export const singleNexusE1Clean = (g: Game): boolean =>
!g.response && !g.phaseOpening && !g.decision && !g.pendingTreacheryDiscard && !g.truthtrance;
export function singleNexusE1Inventory(g: Game): void {
if (g.homeworlds) nativeHomeworldInventory(g);
else {
for (const p of g.players) {
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((n, f) => n + f, 0), 20);
if (p.elites) assert.equal(p.elites.reserves + p.elites.tanks +
Object.values(p.elites.forces).reduce((n, f) => n + f, 0), p.faction === 'ixians' ? 7 : p.faction === 'emperor' ? 5 : 3);
}
assert.deepEqual([...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand),
...(g.ixSetupCards ?? []), ...(g.auction?.cards.slice(g.auction.index) ?? [])].map(c => c.id).sort(),
treacheryDeck(g.expansions).map(c => c.id).sort());
if (g.leaderSkills) validateLeaderSkills(g.leaderSkills, g.players);
if (g.discoveries) validateDiscoveryState(g.discoveries);
}
validateNexusCards(g.nexusCards!.cards!, g.players);
}
function permutation(source: readonly string[], desired: readonly string[]): number[] {
assert.deepEqual([...source].sort(), [...desired].sort());
const current = [...source], rolls: number[] = [];
for (let i = current.length - 1; i > 0; i--) {
const j = current.indexOf(desired[i]); assert.ok(j >= 0 && j <= i);
rolls.push(Math.floor((j + 0.5) * 0x100000000 / (i + 1)));
[current[i], current[j]] = [current[j], current[i]];
}
assert.deepEqual(current, desired); return rolls;
}
function originalLottery<T>(rolls: readonly number[], operation: () => T): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const native = crypto.getRandomValues.bind(crypto); let cursor = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1) array[0] = rolls[cursor++] ?? 0xffffffff;
else Reflect.apply(native, crypto, [array]);
return array;
};
try { return operation(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
export function stepSingleNexusE1(g: Game, step: SingleNexusE1Step, actions?: SingleNexusE1Step[]): Game {
actions?.push(structuredClone(step));
return withClassicDiscoveryNexusLottery(() => applyAction(singleNexusE1Reload(g), step.actor, step.action));
}
function transition(g: Game, step: SingleNexusE1Step, actions: SingleNexusE1Step[]): SingleNexusE1Transition {
return { before: singleNexusE1Reload(g), step: structuredClone(step), after: stepSingleNexusE1(g, step, actions) };
}
/** Stop at every meaningful owned aftermath; never invent a plan, rescue or replacement. */
export function nextSingleNexusE1Step(g: Game): SingleNexusE1Step | null {
if (singleNexusE1Clean(g) && g.nexusCards?.phase?.stage === 'drawing') {
const actor = g.nexusCards.phase.eligible.find(id => !g.nexusCards!.phase!.done.includes(id)); assert.ok(actor);
return { actor, action: { type: 'nexusCardChoice', turn: g.turn,
card: g.nexusCards.cards!.hands[actor] ?? null, choice: 'keep', ownRedraws: 0 } };
}
if (!g.response && !g.phaseOpening && g.decision) {
const d = g.decision;
if (['sukRescue', 'ixSubstitution', 'battleCards', 'faceDance', 'strongholdCopy'].includes(d.kind) ||
d.kind === 'techToken' && !!g.battle) return null;
if (d.kind === 'leaderSkillVisibility') return { actor: d.player,
action: { type: 'leaderSkillVisibility', event: d.event, hide: true } };
if (d.kind === 'guildShipment' || d.kind === 'homeworldShipmentGuild') return { actor: d.player,
action: { type: 'decision', allow: true, ...(d.kind === 'homeworldShipmentGuild' ? { event: d.event } : {}) } };
if (d.kind === 'battleLosses') {
const elite = Math.max(...d.options.map(o => o.elite));
return { actor: d.player, action: { type: 'decision', choice: d.options.findIndex(o => o.elite === elite) } };
}
if (d.kind === 'techToken') return { actor: d.player, action: { type: 'decision', token: d.choices[0] } };
}
return nextHomeworldSkillsDiscoveryStep(g);
}
export function advanceSingleNexusE1(g: Game, until: (g: Game) => boolean, actions?: SingleNexusE1Step[]): Game {
for (let n = 0; n < 3000; n++) {
if (until(g)) return g;
assert.notEqual(g.status, 'finished');
const next = nextSingleNexusE1Step(g);
assert.ok(next, `Choose the original ${g.decision?.kind ?? 'sealed plan'} at ${g.turn}/${g.phase}.`);
g = stepSingleNexusE1(g, next, actions);
}
throw Error('Original single E1 chronology did not reach its owned boundary.');
}
export function settleSingleNexusE1(g: Game, actions?: SingleNexusE1Step[]): Game {
return advanceSingleNexusE1(g, s => singleNexusE1Clean(s) && !s.pendingShipment && !s.pendingHomeworldShipment, actions);
}
export function createSingleNexusE1Setup(options: SingleNexusE1Options = {}): SingleNexusE1Setup {
const native = options.native ?? (options.initial?.players.some(p => p.faction === 'tleilaxu') ? 'tleilaxu' : 'ixians');
const roster: readonly FactionId[] = options.roster ?? [native, 'guild', 'fremen'];
let g = options.initial ? singleNexusE1Reload(options.initial)
: createGame('SINGLENEXUSE1', newPlayer(roster[0], roster[0], roster[0]), options.advanced ?? true, ['ix']);
if (!options.initial) for (const faction of roster.slice(1)) joinGame(g, newPlayer(faction, faction, faction));
assert.deepEqual(g.expansions, ['ix']); assert.equal(g.advanced, options.advanced ?? g.advanced);
assert.equal(g.players.filter(p => p.faction === native).length, 1);
assert.ok(g.players.every(p => p.faction === native || ['guild', 'fremen', 'emperor', 'atreides', 'harkonnen', 'beneGesserit'].includes(p.faction)));
assert.ok(g.status === 'lobby' || g.status === 'setup' && g.turn === 1 && g.phase === 0 &&
(!g.leaderSkills || g.leaderSkills.assignments.length === 0) && g.players.every(p => !p.traitors.length &&
!p.faceDancers?.length && p.leaders.every(l => !l.dead && !l.usedAt)), 'Never convert a played save.');
const skills = options.skills ?? !!g.leaderSkills, actions: SingleNexusE1Step[] = [];
const flags = { homeworlds: options.homeworlds ?? !!g.homeworlds,
techTokens: options.tech ?? !!g.techTokens, strongholdCards: options.strongholds ?? !!g.strongholdCards };
const discovery = options.discovery ?? !!g.discoveryEnabled;
const owner = g.players.find(p => p.faction === native)!.id;
const guild = g.players.find(p => p.faction === 'guild')?.id ?? '';
const fremen = g.players.find(p => p.faction === 'fremen')?.id ?? '';
const collector = native === 'ixians' ? owner : guild;
const target = nativeHomeworldPlayer(g, guild || owner).leaders.slice().sort((a, b) => b.strength - a.strength)[0].id;
const selected: LeaderSkillId[] = [];
for (const p of g.players) {
const desired: LeaderSkillId = p.id === collector ? options.collectorSkill ?? 'suk-graduate' : 'prana-bindu-adept';
selected.push(!selected.includes(desired) ? desired :
(['swordmaster-of-ginaz', 'warmaster', 'killer-medic', 'sandmaster', 'planetologist'] as const).find(s => !selected.includes(s))!);
}
if (g.status === 'lobby') {
for (const [type, enabled] of Object.entries(flags)) if (!!g[type as keyof typeof flags] !== enabled)
g = stepSingleNexusE1(g, { actor: g.host, action: { type, enabled } }, actions);
g.discoveryEnabled = discovery; g.nexusCards ??= { cards: null, phase: null };
} else {
for (const [type, enabled] of Object.entries(flags)) assert.equal(!!g[type as keyof typeof flags], enabled);
assert.equal(!!g.discoveryEnabled, discovery); assert.equal(!!g.leaderSkills, skills);
assert.ok(g.nexusCards?.cards, 'A saved original deal cannot acquire a new Nexus deck.');
}
const initial = singleNexusE1Reload(g);
if (g.status === 'lobby') {
for (const p of g.players) if (!p.ready) g = stepSingleNexusE1(g, { actor: p.id, action: { type: 'ready' } }, actions);
const skillSource = LEADER_SKILL_CARDS.map(c => c.id), rest = skillSource.filter(s => !selected.includes(s));
const skillOrder = selected.flatMap(s => [s, rest.shift()!]).concat(rest);
const cards = treacheryDeck(g.expansions), remaining = [...cards], prefix: Card[] = [];
for (const [i, p] of g.players.entries()) for (let j = 0; j < (p.faction === 'harkonnen' ? 2 : 1); j++) {
const kind = options.startingKinds?.[i] ?? (i < 5 ? 'worthless' : 'shield');
const at = remaining.findIndex(c => c.kind === kind); assert.ok(at >= 0); prefix.push(remaining.splice(at, 1)[0]);
}
const rolls = [...(skills ? permutation(skillSource, skillOrder) : []),
...(discovery ? Array<number>(7).fill(0xffffffff) : []), ...Array<number>(NEXUS_FACTIONS.length - 1).fill(0xffffffff),
...permutation(cards.map(c => c.id), [...prefix, ...remaining].map(c => c.id))];
// A supplied lobby's original lottery is not overwritten.
g = options.initial ? (skills ? initializeLeaderSkillsGameForAudit(g) : initializeNexusGameForAudit(g))
: originalLottery(rolls, () => skills ? initializeLeaderSkillsGameForAudit(g) : initializeNexusGameForAudit(g));
}
const setup = singleNexusE1Reload(g);
let offered: Game | null = null, ixOffer: SingleNexusE1Transition | null = null, trainer: string | null = null;
for (let n = 0; g.status === 'setup' && n < 250; n++) {
let next: SingleNexusE1Step;
if (g.decision?.kind === 'ixSetup') {
const actor = g.decision.player, offer = viewGame(g, actor).ixTechnology!.setup!;
const card = offer.find(c => c.kind === 'worthless') ?? offer[0]; assert.ok(card);
ixOffer = transition(g, { actor, action: { type: 'decision', card: card.id } }, actions); g = ixOffer.after; continue;
}
if (g.setupStage === 'leaderSkills') {
offered ??= singleNexusE1Reload(g);
const actor = Object.keys(g.leaderSkills!.offers)[0], view = viewGame(g, actor).leaderSkills!, offer = view.offer!;
const wanted = selected[g.players.findIndex(p => p.id === actor)];
const skill = options.initial ? offer.cards.find(s => !view.unavailableSkills?.[s])
: offer.cards.find(s => s === wanted && !view.unavailableSkills?.[s]); assert.ok(skill);
const eligible = new Set(view.eligibleLeaders.map(l => l.id));
const leader = nativeHomeworldPlayer(g, actor).leaders.filter(l => eligible.has(l.id)).sort((a, b) => b.strength - a.strength)[0]; assert.ok(leader);
if (actor === collector) trainer = leader.id;
next = { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: leader.id } };
} else {
const candidate = nextSingleNexusE1Step(g); assert.ok(candidate); next = candidate;
if (next.action.type === 'traitor') {
const choices = nativeHomeworldPlayer(g, next.actor).traitorChoices;
next.action.leader = choices.find(id => id !== (trainer ?? target)) ?? choices[0];
}
}
if (next.action.type === 'fremenSetup') next.action.placements = { sietch_tabr: 0, false_wall_south: 10, false_wall_west: 0 };
if (!options.initial && native === 'tleilaxu' && g.setupStage === 'forces' && next.action.type === 'fremenSetup') {
const held = new Set(g.players.flatMap(p => p.traitors));
const source = traitorDeck(g.players, true).filter(id => !held.has(id)), dancerLeader = trainer ?? target;
assert.ok(source.includes(dancerLeader)); actions.push(structuredClone(next));
g = originalLottery(permutation(source, [dancerLeader, ...source.filter(id => id !== dancerLeader)]),
() => applyAction(singleNexusE1Reload(g), next.actor, next.action));
} else g = stepSingleNexusE1(g, next, actions);
}
assert.equal(g.status, 'playing'); singleNexusE1Inventory(g);
return { initial, setup, offered, ixOffer, afterSetup: g, owner, guild, fremen, collector,
trainer, dancerLeader: trainer ?? target, actions };
}
export function shipSingleNexusE1(g: Game, actor: string, destination: string, amount: number, elite = 0,
actions: SingleNexusE1Step[] = []): SingleNexusE1Transition {
assert.equal(g.phase, 5); assert.equal(g.active, actor); assert.ok(singleNexusE1Clean(g));
const sources = g.homeworlds ? nativeShipmentSources(viewGame(g, actor), amount, elite) : undefined;
if (g.homeworlds) assert.ok(sources);
let action: Action;
if (destination.startsWith('homeworld:')) {
assert.ok(sources);
const choice = homeworldShipmentChoice(viewGame(g, actor), destination,
Object.fromEntries(Object.entries(sources).filter(([, f]) => f.normal + f.elite > 0)));
assert.ok(choice.action, choice.blocked ?? undefined); action = choice.action;
} else action = { type: 'ship', territory: destination, sector: destination === 'shrine' ? 0 : territory(destination).sectors[0],
amount, elite, allyPayment: 0, ...(sources ? { homeworldSources: sources } : {}) };
const result = transition(g, { actor, action }, actions); result.after = settleSingleNexusE1(result.after, actions); return result;
}
/** Original turn-one reveal and free entry, real both-pile blow/Maker, alliance closing and qualified draw. */
export function createSingleNexusE1Programme(options: SingleNexusE1Options = {}): SingleNexusE1Programme {
const s = createSingleNexusE1Setup(options), actions = s.actions; assert.ok(s.guild && s.fremen);
let g = singleNexusE1Reload(s.afterSetup);
const discovery = !!g.discoveries;
frontNativeHomeworldSpice(g, c => 'territory' in c && (discovery ? c.discovery === 'discovery-hagga-basin' : !c.discovery && c.territory === 'hagga_basin'), 0);
if (g.advanced) frontNativeHomeworldSpice(g, c => 'territory' in c && !c.discovery && c.territory === 'broken_land', 1);
const second = g.advanced ? 2 : 1;
frontNativeHomeworldSpice(g, c => 'worm' in c && (discovery ? !!c.greatMaker : !c.greatMaker && !c.suppressed), second);
frontNativeHomeworldSpice(g, c => 'territory' in c && !c.discovery && c.territory === 'rock_outcroppings', second + 1);
if (g.advanced) frontNativeHomeworldSpice(g, c => 'territory' in c && !c.discovery && c.territory === 'oh_gap', second + 2);
let firstArrival: SingleNexusE1Transition | null = null, reveal: SingleNexusE1Transition | null = null;
if (discovery) {
g = advanceSingleNexusE1(g, q => q.phase === 1 && singleNexusE1Clean(q), actions);
for (let n = 0; !g.discoveries!.tokens.some(t => t.face === 'shrine' && t.status === 'placed') && n < 100; n++) {
const next = nextSingleNexusE1Step(g); assert.ok(next); actions.push(structuredClone(next));
g = withClassicDiscoveryNexusToken(g, 'shrine', () => applyAction(singleNexusE1Reload(g), next.actor, next.action));
}
assert.ok(g.discoveries!.tokens.some(t => t.face === 'shrine' && t.status === 'placed'));
g = advanceSingleNexusE1(g, q => q.phase === 5 && q.active === s.collector && singleNexusE1Clean(q), actions);
const placement = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'];
const sources = g.homeworlds ? nativeShipmentSources(viewGame(g, s.collector), 3, s.collector === s.owner && options.native !== 'tleilaxu' ? 1 : 0) : undefined;
if (g.homeworlds) assert.ok(sources);
firstArrival = transition(g, { actor: s.collector, action: { type: 'ship', territory: placement.territory, sector: placement.sector,
amount: 3, elite: nativeHomeworldPlayer(g, s.collector).faction === 'ixians' ? 1 : 0, allyPayment: 0,
...(sources ? { homeworldSources: sources } : {}) } }, actions);
g = settleSingleNexusE1(firstArrival.after, actions); firstArrival.after = singleNexusE1Reload(g);
g = advanceSingleNexusE1(g, q => q.phase === 7 && singleNexusE1Clean(q), actions);
const token = g.discoveries!.tokens.find(t => t.face === 'shrine' && t.status === 'placed')!;
g = stepSingleNexusE1(g, { actor: s.collector, action: { type: 'discovery', token: token.id, reveal: false } }, actions);
reveal = transition(g, { actor: s.collector, action: { type: 'discovery', token: token.id, reveal: true } }, actions); g = reveal.after;
}
g = advanceSingleNexusE1(g, q => q.phase === 8 && singleNexusE1Clean(q), actions);
let firstMentat: SingleNexusE1Transition | null = null;
for (let n = 0; g.turn === 1 && n < 200; n++) {
const next = nextSingleNexusE1Step(g); assert.ok(next);
const result = transition(g, next, actions); g = result.after; if (g.turn === 2) firstMentat = result;
}
assert.ok(firstMentat);
let entry: SingleNexusE1Transition | null = null, vote: Game | null = null, ride: SingleNexusE1Transition | null = null;
if (discovery) {
g = advanceSingleNexusE1(g, q => q.decision?.kind === 'discoveryEntry' && q.decision.player === s.collector, actions);
const view = viewGame(g, s.collector), sources = view.discoveryEntry!.sources;
const action = discoveryEntryMoveAction(view, nativeHomeworldPlayer(g, s.collector).faction === 'ixians'
? sources.map(source => ({ ...source, normal: Math.min(source.normal, 1), elite: Math.min(source.elite, 1) })) : sources); assert.ok(action);
entry = transition(g, { actor: s.collector, action }, actions); g = entry.after;
g = advanceSingleNexusE1(g, q => q.decision?.kind === 'greatMakerVote', actions); vote = singleNexusE1Reload(g);
while (g.decision?.kind === 'greatMakerVote') g = stepSingleNexusE1(g,
{ actor: g.decision.player, action: { type: 'decision', event: g.decision.event, yes: true } }, actions);
assert.ok(g.decision?.kind === 'greatMakerRide' && g.decision.player === s.fremen);
const actionRide = greatMakerRideAction(viewGame(g, s.fremen), 'polar_sink', 0, 2, g.advanced ? 1 : 0); assert.ok(actionRide);
ride = transition(g, { actor: s.fremen, action: actionRide }, actions); g = ride.after;
}
g = advanceSingleNexusE1(g, q => !!q.nexus && !q.spiceWindow && !q.spiceResolution && singleNexusE1Clean(q), actions);
for (const [actor, target] of [[s.guild, s.fremen], [s.fremen, s.guild]])
g = stepSingleNexusE1(g, { actor, action: { type: 'alliance', target } }, actions);
const alliance = singleNexusE1Reload(g);
g = advanceSingleNexusE1(g, q => q.nexusCards?.phase?.stage === 'drawing' && singleNexusE1Clean(q), actions);
const drawing = singleNexusE1Reload(g), cards = g.nexusCards!.cards!, face = nativeHomeworldPlayer(g, s.owner).faction;
const at = cards.deck.indexOf(face); assert.ok(at >= 0); cards.deck.unshift(cards.deck.splice(at, 1)[0]);
const draw = transition(g, { actor: s.owner, action: { type: 'nexusCardChoice', turn: g.turn,
card: cards.hands[s.owner] ?? null, choice: 'draw', ownRedraws: 0 } }, actions); g = draw.after;
g = advanceSingleNexusE1(g, q => q.phase === 5 && singleNexusE1Clean(q), actions); singleNexusE1Inventory(g);
return { ...s, game: g, firstArrival, reveal, entry, vote, ride, firstMentat, alliance, drawing, draw };
}
export function openSingleNexusE1Battle(g: Game, first: string, second: string, location: string,
actions?: SingleNexusE1Step[]): Game {
g = advanceSingleNexusE1(g, q => q.phase === 6 && singleNexusE1Clean(q) && !!q.active, actions);
const choice = viewGame(g, g.active!).battleChoices.find(b => b.territory === location &&
[first, second].includes(b.attacker) && [first, second].includes(b.defender)); assert.ok(choice);
g = stepSingleNexusE1(g, { actor: choice.chooser, action: { type: 'chooseBattle', territory: location,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } }, actions);
return advanceSingleNexusE1(g, q => !!q.battle && singleNexusE1Clean(q) && !q.battle.preparation && q.battle.preLeader?.closed !== false, actions);
}
export function revealSingleNexusE1Plans(g: Game, plans: readonly SingleNexusE1Step[], actions?: SingleNexusE1Step[]): Game {
for (const plan of plans) {
g = stepSingleNexusE1(g, plan, actions);
while (g.response || g.phaseOpening || g.decision?.kind === 'fullPlanOffer') {
const next = nextSingleNexusE1Step(g); assert.ok(next); g = stepSingleNexusE1(g, next, actions);
}
}
assert.ok(g.battle?.revealed); return g;
}
export function finishSingleNexusE1Battle(g: Game, actions?: SingleNexusE1Step[]): Game {
for (let n = 0; n < 600; n++) {
if (!g.battle && singleNexusE1Clean(g)) return g;
let next = nextSingleNexusE1Step(g);
if (!next && !g.response && !g.phaseOpening) {
const d = g.decision; assert.ok(d);
if (d.kind === 'battleCards') next = { actor: d.player, action: { type: 'decision', discard: [] } };
else if (d.kind === 'techToken') next = { actor: d.player, action: { type: 'decision', token: d.choices[0] } };
else if (d.kind === 'faceDance') next = { actor: d.player, action: { type: 'decision', reveal: false } };
else throw Error(`Consume the real ${d.kind} before continuing.`);
}
assert.ok(next); g = stepSingleNexusE1(g, next, actions);
}
throw Error('Original single E1 battle aftermath did not finish.');
}
/** Same public physical/held inputs as the existing paired quote, with optional training and native rules. */
export function quoteSingleNexusE1Battle(g: Game): BattleResolutionQuote {
const b = g.battle; assert.ok(b?.revealed);
const side = (actor: string): ResolutionCombatant => {
const p = nativeHomeworldPlayer(g, actor), view = viewGame(g, actor).battle!; assert.ok(view.ownForces);
return { id: actor, faction: p.faction, ally: p.ally, spice: p.spice, hand: p.hand,
plan: b.plans[actor], leader: p.leaders.find(l => l.id === b.plans[actor].leader), forces: view.ownForces,
stronghold: view.strongholdEffects[actor], leaderSkills: (g.leaderSkills?.assignments ?? []).filter(a => a.owner === actor).map(a => ({
skill: a.skill, leader: a.leader, faceUp: !b.leaderSkillHidden?.[actor], captured: false,
})) };
};
const home = homeworldBattleLocation(g, b.territory);
return quoteBattleResolution({ advanced: g.advanced, typedCasualties: !!g.homeworlds, turn: g.turn, territory: b.territory,
...(home ? { homeworld: { native: home.native, card: home.card, side: home.side, nativeForces: { ...home.forces[home.native] } } } : {}),
aggressor: viewGame(g, g.host).battle!.aggressor, attacker: side(b.attacker), defender: side(b.defender),
voters: viewGame(g, g.host).battle!.traitorVoters.map(actor => ({ id: actor,
beneficiary: [b.attacker, b.defender].includes(actor) ? actor : nativeHomeworldPlayer(g, actor).ally!,
called: b.traitorCalls[actor] ?? false, traitors: nativeHomeworldPlayer(g, actor).traitors })), participants: g.players,
physicalCards: [...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand)],
pendingAuditorPresent: false, pendingRetentionPresent: false });
}
+350
View File
@@ -0,0 +1,350 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeLeaderSkillsGameForAudit, initializeNexusGameForAudit,
joinGame, newPlayer, viewGame,
} from '../game/engine';
import type { Action, Game, Player } from '../game/engine';
import type { FactionId } from '../game/catalog';
import type { Difficulty } from '../game/bot-profiles';
import type { Card } from '../game/cards';
import type { LeaderSkillId } from '../game/leader-skill-cards';
import { botActions } from '../game/bots';
import { territory } from '../game/board';
import { treacheryDeck } from '../game/cards';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards';
import { richeseCards } from '../game/richese-cards';
import { validateLeaderSkills } from '../game/leader-skills';
import { nativeShipmentSources } from '../game/homeworld-options';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import { DISCOVERY_CARD_PLACEMENTS, validateDiscoveryState } from '../game/discoveries';
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
import { greatMakerRideAction } from '../game/great-maker-options';
import { choamPowerAction, choamPowerPlays } from '../game/choam-power-options';
import { nativeHomeworldInventory, nativeHomeworldPlayer, nativeHomeworldLeader, frontNativeHomeworldSpice } from './fixture-homeworld-native-e1e2-modules';
import { nextHomeworldPairedNexusStep } from './fixture-homeworld-paired-nexus';
import { withClassicDiscoveryNexusLottery, withClassicDiscoveryNexusToken } from './fixture-discovery-classic-nexus';
export type SingleNexusE2Native = 'choam' | 'richese';
export type SingleNexusE2Step = { actor: string; action: Action };
export type SingleNexusE2Transition = { before: Game; step: SingleNexusE2Step; after: Game };
export type SingleNexusE2Options = {
/** Only an original undealt lobby or its original, unassigned turn-one setup. */
initial?: Game;
native?: SingleNexusE2Native;
roster?: readonly FactionId[];
advanced?: boolean;
skills?: boolean;
homeworlds?: boolean;
discovery?: boolean;
tech?: boolean;
strongholds?: boolean;
skill?: LeaderSkillId;
/** Original first deal selection only; never replaces a supplied deal. */
fuelKind?: 'special' | 'shield';
};
export type SingleNexusE2Setup = {
initial: Game; setup: Game; offered: Game | null; afterSetup: Game; game: Game;
owner: string; others: string[]; trainer: string | null; actions: SingleNexusE2Step[];
};
export type SingleNexusE2Programme = SingleNexusE2Setup & {
firstMarker: SingleNexusE2Transition | null;
entry: SingleNexusE2Transition | null;
ride: SingleNexusE2Transition | null;
vote: Game | null;
alliance: Game; draw: SingleNexusE2Transition;
};
export const singleE2Reload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game;
export const singleE2Player = nativeHomeworldPlayer;
export const singleE2Leader = nativeHomeworldLeader;
export const singleE2Clean = (game: Game): boolean =>
!game.response && !game.phaseOpening && !game.decision && !game.pendingTreacheryDiscard && !game.truthtrance;
/** Persist minimal profiles on the real owning seat before projecting its window. */
export function singleE2Policy(game: Game, actor: string, difficulty: Difficulty): Action[] {
const saved = singleE2Reload(game); singleE2Player(saved, actor).bot = difficulty;
return botActions(viewGame(singleE2Reload(saved), actor));
}
export function singleE2Inventory(game: Game): void {
if (game.homeworlds) nativeHomeworldInventory(game);
else for (const p of game.players) {
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((a, n) => a + n, 0), 20, p.faction);
if (p.elites) assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((a, n) => a + n, 0), p.faction === 'emperor' ? 5 : 3);
}
if (game.leaderSkills) validateLeaderSkills(game.leaderSkills, game.players);
if (game.discoveries) validateDiscoveryState(game.discoveries);
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? [])];
assert.deepEqual(cards.map(c => c.id).sort(), [...treacheryDeck(['choam']),
...(game.players.some(p => p.faction === 'richese') ? richeseCards() : [])].map(c => c.id).sort());
validateNexusCards(game.nexusCards!.cards!, game.players);
const nx = game.nexusCards!.cards!;
assert.deepEqual([...nx.deck, ...nx.discard, ...Object.values(nx.hands).filter((c): c is FactionId => c !== null)].sort(), [...NEXUS_FACTIONS].sort());
}
export function singleE2Step(game: Game, step: SingleNexusE2Step, actions?: SingleNexusE2Step[]): Game {
actions?.push(structuredClone(step));
return withClassicDiscoveryNexusLottery(() => applyAction(singleE2Reload(game), step.actor, step.action));
}
export function nextSingleE2Step(game: Game): SingleNexusE2Step | null {
if (!game.response && !game.phaseOpening && game.decision) {
const d = game.decision;
if (['sukRescue', 'battleCards', 'techToken'].includes(d.kind)) return null;
if (d.kind === 'guildShipment' || d.kind === 'homeworldShipmentGuild')
return { actor: d.player, action: { type: 'decision', allow: true,
...(d.kind === 'homeworldShipmentGuild' ? { event: d.event } : {}) } };
if (d.kind === 'richeseUnbid') return { actor: d.player,
action: { type: 'decision', event: game.richeseBidding!.event, keep: false } };
}
const step = nextHomeworldPairedNexusStep(game);
if (step?.action.type === 'stormDial') step.action.amount = game.turn === 1 ? 0 : 1;
return step;
}
export function advanceSingleE2(game: Game, until: (g: Game) => boolean, actions?: SingleNexusE2Step[]): Game {
for (let n = 0; n < 3000; n++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const step = nextSingleE2Step(game);
assert.ok(step, `Choose the actual single E2 ${game.decision?.kind ?? 'sealed plan'} at ${game.turn}/${game.phase}.`);
game = singleE2Step(game, step, actions);
}
throw Error('Original single E2 chronology did not reach its owned boundary.');
}
export function settleSingleE2(game: Game, actions?: SingleNexusE2Step[]): Game {
return advanceSingleE2(game, g => singleE2Clean(g) && !g.pendingShipment && !g.pendingHomeworldShipment && !g.pendingChoamWorthless, actions);
}
function transition(game: Game, step: SingleNexusE2Step, actions: SingleNexusE2Step[]): SingleNexusE2Transition {
return { before: singleE2Reload(game), step: structuredClone(step), after: singleE2Step(game, step, actions) };
}
function permutation(source: readonly string[], desired: readonly string[]): number[] {
assert.deepEqual([...source].sort(), [...desired].sort());
const working = [...source], rolls: number[] = [];
for (let i = working.length - 1; i > 0; i--) {
const j = working.indexOf(desired[i]); assert.ok(j >= 0 && j <= i);
rolls.push(Math.floor((j + 0.5) * 0x100000000 / (i + 1)));
[working[i], working[j]] = [working[j], working[i]];
}
return rolls;
}
function lottery<T>(rolls: readonly number[], operation: () => T): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const native = crypto.getRandomValues.bind(crypto); let index = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1) array[0] = rolls[index++] ?? 0xffffffff;
else Reflect.apply(native, crypto, [array]);
return array;
};
try { return operation(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
export function createSingleE2Setup(options: SingleNexusE2Options = {}): SingleNexusE2Setup {
const native = options.native ?? options.initial?.players.find(p => p.faction === 'choam' || p.faction === 'richese')?.faction ?? 'richese';
const roster = options.roster ?? [native, 'guild', 'fremen'] as readonly FactionId[];
const advanced = options.advanced ?? options.initial?.advanced ?? true;
let game = options.initial ? singleE2Reload(options.initial)
: createGame('SINGLENEXUSE2', newPlayer(roster[0], roster[0], roster[0]), advanced, ['choam']);
if (!options.initial) for (const faction of roster.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
assert.equal(game.advanced, advanced); assert.deepEqual(game.expansions, ['choam']);
assert.equal(game.players.filter(p => p.faction === native).length, 1);
assert.ok(game.players.every(p => p.faction === native || ['atreides', 'harkonnen', 'guild', 'fremen', 'beneGesserit', 'emperor'].includes(p.faction)));
assert.ok(game.status === 'lobby' || game.status === 'setup' && game.turn === 1 && game.phase === 0 &&
(!game.leaderSkills || game.leaderSkills.assignments.length === 0));
const skills = options.skills ?? (options.initial?.status === 'setup' ? !!game.leaderSkills : true);
const discovery = options.discovery ?? !!game.discoveryEnabled;
const modules = { homeworlds: options.homeworlds ?? !!game.homeworlds,
techTokens: options.tech ?? !!game.techTokens, strongholdCards: options.strongholds ?? !!game.strongholdCards };
assert.ok(!modules.strongholdCards || advanced);
const owner = game.players.find(p => p.faction === native)!.id, actions: SingleNexusE2Step[] = [];
const requested = options.skill ?? 'suk-graduate';
if (game.status === 'lobby') {
for (const [type, enabled] of Object.entries(modules)) if (!!game[type as keyof typeof modules] !== enabled)
game = singleE2Step(game, { actor: game.host, action: { type, enabled } }, actions);
game.discoveryEnabled = discovery; game.nexusCards ??= { cards: null, phase: null };
} else {
for (const [type, enabled] of Object.entries(modules)) assert.equal(!!game[type as keyof typeof modules], enabled);
assert.equal(!!game.discoveryEnabled, discovery); assert.equal(!!game.leaderSkills, skills);
assert.ok(game.nexusCards?.cards, 'Never attach Nexus to a played deal.');
}
const initial = singleE2Reload(game);
if (game.status === 'lobby') {
for (const p of game.players) if (!p.ready) game = singleE2Step(game, { actor: p.id, action: { type: 'ready' } }, actions);
const passive: LeaderSkillId[] = ['warmaster', 'sandmaster', 'killer-medic', 'prana-bindu-adept', 'swordmaster-of-ginaz'].filter(s => s !== requested) as LeaderSkillId[];
const selected = game.players.map(p => p.id === owner ? requested : passive.shift()!);
const skillsSource = LEADER_SKILL_CARDS.map(c => c.id), rest = skillsSource.filter(s => !selected.includes(s));
const skillsOrder = selected.flatMap(s => [s, rest.shift()!]).concat(rest);
const deck = treacheryDeck(['choam']), remaining = [...deck], front: Card[] = [];
for (const p of game.players) for (let n = 0; n < (p.faction === 'harkonnen' ? 2 : 1); n++) {
const kind = p.faction === 'choam' ? options.fuelKind ?? 'special' : 'worthless';
const i = remaining.findIndex(c => c.kind === kind && (kind !== 'special' || c.effect !== 'karama')); assert.ok(i >= 0);
front.push(remaining.splice(i, 1)[0]);
}
const rolls = [...(skills ? permutation(skillsSource, skillsOrder) : []),
...(discovery ? Array<number>(7).fill(0xffffffff) : []),
...Array<number>(NEXUS_FACTIONS.length - 1).fill(0xffffffff),
...permutation(deck.map(c => c.id), [...front, ...remaining].map(c => c.id))];
game = lottery(rolls, () => skills ? initializeLeaderSkillsGameForAudit(game) : initializeNexusGameForAudit(game));
}
const setup = singleE2Reload(game); let offered: Game | null = null, trainer: string | null = null;
for (let n = 0; game.status === 'setup' && n < 250; n++) {
let step: SingleNexusE2Step;
if (game.setupStage === 'leaderSkills' && !game.decision) {
offered ??= singleE2Reload(game);
const actor = Object.keys(game.leaderSkills!.offers)[0], view = viewGame(game, actor).leaderSkills!;
const skill = actor === owner && options.skill ? options.skill
: view.offer!.cards.find(s => !view.unavailableSkills?.[s]);
assert.ok(skill && view.offer!.cards.includes(skill) && !view.unavailableSkills?.[skill],
'A requested skill must exist in the authoritative original private offer.');
const eligible = new Set(view.eligibleLeaders.map(l => l.id));
const leader = singleE2Player(game, actor).leaders.filter(l => eligible.has(l.id)).sort((a, b) => b.strength - a.strength)[0];
assert.ok(skill && leader); if (actor === owner) trainer = leader.id;
step = { actor, action: { type: 'leaderSkill', event: view.offer!.event, skill, leader: leader.id } };
} else {
const next = nextSingleE2Step(game); assert.ok(next); step = next;
if (step.action.type === 'traitor' && trainer) {
const choices = singleE2Player(game, step.actor).traitorChoices;
step.action.leader = choices.find(id => id !== trainer) ?? choices[0];
}
if (step.action.type === 'fremenSetup') step.action.placements = { sietch_tabr: 0, false_wall_south: 10, false_wall_west: 0 };
}
game = singleE2Step(game, step, actions);
}
assert.equal(game.status, 'playing'); singleE2Inventory(game);
return { initial, setup, offered, afterSetup: singleE2Reload(game), game, owner,
others: game.players.filter(p => p.id !== owner).map(p => p.id), trainer, actions };
}
export function shipSingleE2(game: Game, actor: string, destination: string, amount: number,
actions?: SingleNexusE2Step[], sector?: number): Game {
const sources = game.homeworlds ? nativeShipmentSources(viewGame(game, actor), amount, 0) : undefined;
if (game.homeworlds) assert.ok(sources);
let action: Action;
if (destination.startsWith('homeworld:')) {
assert.ok(sources);
const choice = homeworldShipmentChoice(viewGame(game, actor), destination,
Object.fromEntries(Object.entries(sources).filter(([, f]) => f.normal + f.elite > 0)));
assert.ok(choice.action, choice.blocked ?? undefined); action = choice.action;
} else action = { type: 'ship', territory: destination, sector: sector ?? (destination === 'shrine' ? 0 : territory(destination).sectors[0]),
amount, elite: 0, allyPayment: 0, ...(sources ? { homeworldSources: sources } : {}) };
return settleSingleE2(singleE2Step(game, { actor, action }, actions), actions);
}
/** Native starts, real first marker/parent entry, END Mentat, both Spice piles,
* Maker or worm, settled alliance and a qualified original closing draw. */
export function createSingleE2Programme(options: SingleNexusE2Options = {}): SingleNexusE2Programme {
const f = createSingleE2Setup(options), actions = f.actions;
const guild = f.game.players.find(p => p.faction === 'guild')?.id;
const fremen = f.game.players.find(p => p.faction === 'fremen')?.id;
assert.ok(guild && fremen, 'The closing programme needs original Guild/Fremen alliance seats. Setup itself supports two through six.');
let game = f.game; const discovery = !!game.discoveries;
frontNativeHomeworldSpice(game, c => 'territory' in c && (discovery ? c.discovery === 'discovery-hagga-basin' : !c.discovery && c.territory === 'hagga_basin'), 0);
if (game.advanced) frontNativeHomeworldSpice(game, c => 'territory' in c && !c.discovery && c.territory === 'broken_land', 1);
const offset = game.advanced ? 2 : 1;
frontNativeHomeworldSpice(game, c => 'worm' in c && (discovery ? !!c.greatMaker : !c.greatMaker && !c.suppressed), offset);
frontNativeHomeworldSpice(game, c => 'territory' in c && !c.discovery && c.territory === 'rock_outcroppings', offset + 1);
if (game.advanced) frontNativeHomeworldSpice(game, c => 'territory' in c && !c.discovery && c.territory === 'oh_gap', offset + 2);
game = advanceSingleE2(game, g => g.phase === 1 && singleE2Clean(g), actions);
if (discovery) {
for (let n = 0; !game.discoveries!.tokens.some(t => t.face === 'shrine' && t.status === 'placed') && n < 100; n++) {
const step = nextSingleE2Step(game); assert.ok(step); actions.push(structuredClone(step));
game = withClassicDiscoveryNexusToken(game, 'shrine', () => applyAction(singleE2Reload(game), step.actor, step.action));
}
assert.ok(game.discoveries!.tokens.some(t => t.face === 'shrine' && t.status === 'placed'));
}
let firstMarker: SingleNexusE2Transition | null = null;
const needed = new Set([...(discovery ? [guild] : []), ...(singleE2Player(game, f.owner).faction === 'richese' ? [f.owner] : [])]);
while (needed.size) {
game = advanceSingleE2(game, g => g.turn === 1 && g.phase === 5 && singleE2Clean(g) && needed.has(g.active!), actions);
const actor = game.active!;
if (actor === f.owner) {
const p = singleE2Player(game, actor), token = p.noField!.tokens.find(t => t.value === 0)!;
firstMarker = transition(game, { actor, action: { type: 'ship', territory: 'habbanya_ridge_sietch',
sector: territory('habbanya_ridge_sietch').sectors[0], noField: token.id, event: p.noFieldEvent, allyPayment: 0 } }, actions);
game = settleSingleE2(firstMarker.after, actions); firstMarker.after = singleE2Reload(game);
game = singleE2Step(game, { actor, action: { type: 'revealNoField', token: token.id, event: singleE2Player(game, actor).noFieldEvent } }, actions);
} else {
const placement = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'];
game = shipSingleE2(game, actor, placement.territory, 3, actions, placement.sector);
}
needed.delete(actor);
if (needed.size) game = singleE2Step(game, { actor, action: { type: 'endMovement' } }, actions);
}
if (discovery) {
game = advanceSingleE2(game, g => g.phase === 7 && singleE2Clean(g), actions);
const token = game.discoveries!.tokens.find(t => t.face === 'shrine' && t.status === 'placed')!;
for (const reveal of [false, true]) game = singleE2Step(game, { actor: guild, action: { type: 'discovery', token: token.id, reveal } }, actions);
}
game = advanceSingleE2(game, g => g.turn === 2, actions);
let entry: SingleNexusE2Transition | null = null, ride: SingleNexusE2Transition | null = null, vote: Game | null = null;
if (discovery) {
game = advanceSingleE2(game, g => g.decision?.kind === 'discoveryEntry' && g.decision.player === guild, actions);
const view = viewGame(game, guild), action = discoveryEntryMoveAction(view, view.discoveryEntry!.sources); assert.ok(action);
entry = transition(game, { actor: guild, action }, actions); game = entry.after;
game = advanceSingleE2(game, g => g.decision?.kind === 'greatMakerVote', actions); vote = singleE2Reload(game);
while (game.decision?.kind === 'greatMakerVote') game = singleE2Step(game,
{ actor: game.decision.player, action: { type: 'decision', event: game.decision.event, yes: true } }, actions);
assert.ok(game.decision?.kind === 'greatMakerRide' && game.decision.player === fremen);
const actionRide = greatMakerRideAction(viewGame(game, fremen), 'polar_sink', 0, 2, game.advanced ? 1 : 0); assert.ok(actionRide);
ride = transition(game, { actor: fremen, action: actionRide }, actions); game = ride.after;
}
game = advanceSingleE2(game, g => !!g.nexus && !g.spiceWindow && !g.spiceResolution && singleE2Clean(g), actions);
for (const [actor, target] of [[guild, fremen], [fremen, guild]])
game = singleE2Step(game, { actor, action: { type: 'alliance', target } }, actions);
const alliance = singleE2Reload(game);
game = advanceSingleE2(game, g => g.nexusCards?.phase?.stage === 'drawing' && singleE2Clean(g), actions);
assert.ok(game.nexusCards!.phase!.eligible.includes(f.owner));
const cards = game.nexusCards!.cards!, face = singleE2Player(game, f.owner).faction;
const at = cards.deck.indexOf(face); assert.ok(at >= 0); cards.deck.unshift(cards.deck.splice(at, 1)[0]);
const draw = transition(game, { actor: f.owner, action: { type: 'nexusCardChoice', turn: game.turn,
card: cards.hands[f.owner] ?? null, choice: 'draw', ownRedraws: 0 } }, actions);
game = advanceSingleE2(draw.after, g => g.phase === 5 && singleE2Clean(g), actions);
return { ...f, game, firstMarker, entry, ride, vote, alliance, draw };
}
export function singleE2Cunning(game: Game, actor: string, fuel?: string, destination = 'shrine'): Action {
const p: Player = singleE2Player(game, actor), view = viewGame(game, actor);
if (p.faction === 'choam') {
const play = choamPowerPlays(view, 'kulon').find(p => p.source === 'nexus' && p.card.id === fuel); assert.ok(play);
const action = choamPowerAction(view, play); assert.ok(action); return action;
}
assert.equal(p.faction, 'richese'); const offer = view.nexusRicheseCunning; assert.ok(offer && !offer.blocked);
return { type: 'ship', territory: destination, sector: destination === 'shrine' ? 0 : territory(destination).sectors[0],
noField: p.noField!.tokens.find(t => t.value === 3)!.id, revealedToken: p.noField!.tokens.find(t => t.value === 5)!.id,
event: p.noFieldEvent, nexus: offer.event, allyPayment: 0 };
}
export function openSingleE2Battle(game: Game, owner: string, opponent: string, location: string, hidden = false): Game {
game = advanceSingleE2(game, g => g.phase === 6 && singleE2Clean(g) && !!g.active);
const choice = viewGame(game, game.active!).battleChoices.find(b => b.territory === location &&
[owner, opponent].includes(b.attacker) && [owner, opponent].includes(b.defender)); assert.ok(choice);
game = singleE2Step(game, { actor: choice.chooser, action: { type: 'chooseBattle', territory: location,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } });
for (let n = 0; n < 200; n++) {
if (game.battle && singleE2Clean(game) && !game.battle.preparation && game.battle.preLeader?.closed !== false) return game;
const next = nextSingleE2Step(game); assert.ok(next);
if (next.action.type === 'leaderSkillVisibility') next.action.hide = next.actor === owner && hidden;
game = singleE2Step(game, next);
}
throw Error('Original single E2 battle did not reach sealed plans.');
}
export function revealSingleE2Plans(game: Game, plans: readonly SingleNexusE2Step[]): Game {
for (const plan of plans) {
game = singleE2Step(game, plan);
while (game.response || game.phaseOpening || game.decision?.kind === 'fullPlanOffer') {
const next = nextSingleE2Step(game); assert.ok(next); game = singleE2Step(game, next);
}
}
assert.ok(game.battle?.revealed); return game;
}
export function finishSingleE2Battle(game: Game): Game {
for (let n = 0; n < 600; n++) {
if (!game.battle && singleE2Clean(game)) return game;
let next = nextSingleE2Step(game);
if (!next && !game.response && !game.phaseOpening) {
const d = game.decision; assert.ok(d);
if (d.kind === 'battleCards') next = { actor: d.player, action: { type: 'decision', discard: [] } };
else if (d.kind === 'techToken') next = { actor: d.player, action: { type: 'decision', token: d.choices[0] } };
else throw Error(`Choose real ${d.kind} before cleanup.`);
}
assert.ok(next); game = singleE2Step(game, next);
}
throw Error('Original single E2 aftermath did not finish.');
}
+425
View File
@@ -0,0 +1,425 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame } from '../game/engine';
import type { Action, Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { ownedTech } from '../game/tech-tokens';
import { nexusFaceDancersAction } from '../game/nexus-tleilaxu-options';
import type { BattleResolutionQuote } from '../game/battle-resolution-quote';
import {
advanceSingleNexusE1 as advance, createSingleNexusE1Programme as programme,
finishSingleNexusE1Battle as finish, openSingleNexusE1Battle as open,
quoteSingleNexusE1Battle as quote, revealSingleNexusE1Plans as plans,
settleSingleNexusE1 as settle, shipSingleNexusE1 as ship, singleNexusE1Army as army,
singleNexusE1Clean as clean, singleNexusE1Inventory as inventory, singleNexusE1Leader as leader,
singleNexusE1Player as player, singleNexusE1Reload as reload, stepSingleNexusE1 as step,
} from './fixture-single-nexus-e1';
function reject(g: Game, actor: string, action: Action): void {
const before = reload(g);
assert.throws(() => applyAction(g, actor, action));
assert.deepEqual(g, before, 'A rejected consumer cannot spend stock, move custody or debit payment.');
}
/** All four minimal saved difficulties consume their own real window after JSON continuation. */
function policies(g: Game, actor: string, select: (action: Action) => boolean,
outcome: (after: Game, action: Action) => void): void {
for (const difficulty of DIFFICULTIES) {
const saved = reload(g); player(saved, actor).bot = difficulty;
const action = botActions(viewGame(reload(saved), actor)).find(select);
assert.ok(action, `${difficulty} consumes its original owned window`);
const after = step(reload(saved), { actor, action }); outcome(after, action);
if (after.status === 'playing') inventory(after);
}
}
function movement(g: Game, actor: string, turn = 2): Game {
return advance(g, s => s.turn === turn && s.phase === 5 && s.active === actor && clean(s));
}
function cunning(g: Game, owner: string, foreign: string): Game {
const offer = viewGame(g, owner).nexusSuboids?.offer; assert.ok(offer && !offer.blocked);
const action: Action = { type: 'nexusSuboids', event: offer.event };
reject(g, foreign, action); reject(g, owner, { ...action, event: 'previous-battle' });
policies(g, owner, a => a.type === 'nexusSuboids', after => {
const resolved = settle(after);
assert.equal(resolved.nexusCards!.cards!.hands[owner], null);
assert.equal(resolved.nexusCards!.cards!.discard.filter(c => c === 'ixians').length, 1);
assert.deepEqual(player(resolved, owner).forces, player(g, owner).forces);
assert.equal(viewGame(resolved, owner).battle!.ownForces!.normalFreeSupport, true);
assert.equal(player(resolved, owner).spice, player(g, owner).spice);
});
const after = settle(step(reload(g), { actor: owner, action })); reject(after, owner, action); return after;
}
function moneyAtHeldBoundary(before: Game, after: Game, receipt: BattleResolutionQuote): void {
for (const p of before.players) {
const debit = receipt.payments.filter(q => q.player === p.id).reduce((n, q) => n + q.ownPayment, 0);
const bounty = receipt.bounty?.player === p.id ? receipt.bounty.amount : 0;
const held = receipt.strongholdIncome.filter(q => q.player === p.id).reduce((n, q) => n + q.amount, 0);
assert.equal(player(after, p.id).spice, p.spice - debit + bounty + held,
'Only the held source pays support/bounty/industry; ordinary Collection has not run.');
}
}
void test('single Ixian bare Nexus Skills lets a surviving original Smuggler collect the printed board spice without another module', () => {
const f = programme({ native: 'ixians', advanced: true, skills: true, collectorSkill: 'smuggler' });
assert.ok(f.trainer);
const pile = Object.entries(f.game.spice).find(([key, amount]) => key.startsWith('rock_outcroppings:') && amount > 0);
assert.ok(pile);
const needed = new Set([f.owner, f.guild]);
let g = reload(f.game);
while (needed.size) {
g = advance(g, s => s.phase === 5 && clean(s) && needed.has(s.active!));
const actor = g.active!;
g = ship(g, actor, 'rock_outcroppings', 2).after;
needed.delete(actor);
if (needed.size) g = step(g, { actor, action: { type: 'endMovement' } });
}
g = open(g, f.owner, f.guild, 'rock_outcroppings');
const purse = player(g, f.owner).spice;
g = plans(g, [
{ actor: f.owner, action: { type: 'battlePlan', leader: f.trainer, dial: 0, support: 0 } },
{ actor: f.guild, action: { type: 'battlePlan', leader: leader(g, f.guild, true), dial: 0, support: 0 } },
]);
assert.equal(quote(g).winner, f.owner);
const strength = player(g, f.owner).leaders.find(l => l.id === f.trainer)!.strength;
g = finish(g);
const receipt = g.lastBattleContext!.smugglerCollection;
assert.ok(receipt);
assert.equal(receipt.stage, 'collected');
assert.equal(receipt.before, pile[1]);
assert.equal(receipt.amount, Math.min(strength, pile[1]));
assert.equal(g.spice[pile[0]], 0, 'Ordinary Collection harvests the remainder after the printed Smuggler amount.');
assert.equal(player(g, f.owner).spice, purse + pile[1], 'The physical pile pays once across Smuggler and ordinary Collection.');
assert.equal(player(g, f.owner).leaders.find(l => l.id === f.trainer)!.dead, false);
inventory(g);
});
for (const skills of [false, true]) void test(`single Ixian ${skills ? 'all14' : 'no-Skills'} original offer, HMS and closing draw continue into native typed losses without a companion`, () => {
const f = programme({ native: 'ixians', advanced: true, skills, homeworlds: true, tech: true, strongholds: true });
assert.ok(f.ixOffer);
const held = player(f.ixOffer.after, f.owner).hand;
assert.equal(held.length, 1); assert.equal(held[0].id, f.ixOffer.step.action.card);
if (skills) {
assert.ok(f.offered && f.trainer);
}
assert.equal(player(f.afterSetup, f.owner).forces['hidden_mobile_stronghold:0'], 6);
assert.equal(player(f.afterSetup, f.owner).elites!.forces['hidden_mobile_stronghold:0'], 3);
assert.equal(player(f.alliance, f.guild).ally, f.fremen);
assert.equal(player(f.alliance, f.fremen).ally, f.guild);
policies(f.draw.before, f.owner, a => a.type === 'nexusCardChoice', after => {
assert.equal(after.nexusCards!.cards!.hands[f.owner], 'ixians');
assert.deepEqual(after.homeworlds!.custody, f.draw.before.homeworlds!.custody);
assert.equal(player(after, f.owner).spice, player(f.draw.before, f.owner).spice);
});
reject(f.draw.after, f.owner, f.draw.step.action);
const arrival = ship(movement(f.game, f.guild), f.guild, 'homeworld:ixians', 3);
assert.deepEqual(army(arrival.after, f.guild, 'homeworld:ixians'), { normal: 3, elite: 0 });
assert.deepEqual(army(arrival.after, f.guild, 'homeworld:guild'), {
normal: army(arrival.before, f.guild, 'homeworld:guild').normal - 3, elite: 0,
});
assert.ok(player(arrival.after, f.guild).spice < player(arrival.before, f.guild).spice,
'A genuine off-planet native source shipment pays its original invoice.');
reject(arrival.after, f.guild, arrival.step.action);
let g = cunning(open(arrival.after, f.owner, f.guild, 'homeworld:ixians'), f.owner, f.guild);
const before = reload(g), lostTech = ownedTech(g.techTokens, f.guild);
g = plans(g, [{ actor: f.owner, action: { type: 'battlePlan', leader: skills ? f.trainer! : leader(g, f.owner, true), dial: 3, support: 0 } },
{ actor: f.guild, action: { type: 'battlePlan', leader: leader(g, f.guild), dial: 0, support: 0 } }]);
const receipt = quote(g); assert.equal(receipt.winner, f.owner);
assert.equal(receipt.payments.find(p => p.player === f.owner)!.ownPayment, 0);
assert.equal(player(g, f.owner).spice, player(before, f.owner).spice);
if (skills) {
g = advance(g, s => s.decision?.kind === 'sukRescue');
const d = g.decision; assert.ok(d?.kind === 'sukRescue');
const choice = d.options.findIndex(o => o.normal === 0 && o.elite === 1 && o.kept?.kind === 'elite'); assert.ok(choice >= 0);
const rescue: Action = { type: 'decision', event: d.event, choice };
reject(g, f.guild, rescue); reject(g, f.owner, { ...rescue, event: 'old-rescue' });
policies(g, f.owner, a => a.type === 'decision' && a.choice !== undefined, (after, action) => {
const saved = d.options[Number(action.choice)];
assert.equal(player(after, f.owner).tanks, player(g, f.owner).tanks + 3 - saved.normal - saved.elite);
assert.equal(player(after, f.owner).elites!.tanks, player(g, f.owner).elites!.tanks + 3 - saved.elite);
assert.equal(player(after, f.owner).spice, player(g, f.owner).spice);
});
g = step(reload(g), { actor: f.owner, action: rescue });
}
g = advance(g, s => s.decision?.kind === 'ixSubstitution');
const remaining = skills ? 2 : 3;
assert.equal(player(g, f.owner).elites!.tanks, player(before, f.owner).elites!.tanks + remaining);
assert.deepEqual(g.pendingIxSubstitution!.losses, { 'homeworld:ixians': remaining });
if (skills) {
assert.deepEqual(g.lastBattleContext!.sukRescue!.physical!.saved, { normal: 0, elite: 1 });
assert.deepEqual(g.lastBattleContext!.sukRescue!.physical!.tanks, { normal: 0, elite: 2 });
}
const residual = reload(g), pool = army(g, f.owner, 'homeworld:ixians');
const exchange: Action = { type: 'decision', sources: { 'homeworld:ixians': 1 }, recover: { 'homeworld:ixians': 1 } };
reject(g, f.guild, exchange);
reject(g, f.owner, { ...exchange, recover: { 'homeworld:ixians': remaining + 1 } });
reject(g, f.owner, { ...exchange, sources: { reserves: 1 } });
reject(g, f.owner, { ...exchange, recover: { 'homeworld:tleilaxu': 1 } });
policies(g, f.owner, a => a.type === 'decision' && (a.sources !== undefined || a.decline === true), (after, action) => {
const count = action.decline ? 0 : Object.values(action.recover as Record<string, number>).reduce((n, f) => n + f, 0);
assert.equal(player(after, f.owner).elites!.tanks, player(residual, f.owner).elites!.tanks - count);
assert.deepEqual(army(after, f.owner, 'homeworld:ixians'), { normal: pool.normal - count, elite: pool.elite + count });
assert.equal(player(after, f.owner).spice, player(residual, f.owner).spice);
});
g = step(reload(g), { actor: f.owner, action: exchange });
assert.deepEqual(army(g, f.owner, 'homeworld:ixians'), { normal: pool.normal - 1, elite: pool.elite + 1 });
assert.equal(player(g, f.owner).elites!.tanks, player(residual, f.owner).elites!.tanks - 1);
assert.equal(player(g, f.owner).tanks, player(residual, f.owner).tanks);
reject(g, f.owner, exchange); g = finish(g);
assert.deepEqual(army(g, f.guild, 'homeworld:ixians'), { normal: 0, elite: 0 });
assert.equal(player(g, f.guild).tanks, player(before, f.guild).tanks + 3);
for (const token of lostTech) assert.equal(g.techTokens![token].owner, f.owner);
reject(g, f.guild, { type: 'nexusFaceDancers', event: g.lastBattleContext!.event }); inventory(g);
});
void test('single Ixian visitor rescue remains source-local: a native free strength bonus cannot waive visitor paid support or create a borrowed return', () => {
const f = programme({ native: 'ixians', advanced: true, skills: true, homeworlds: true, tech: true, strongholds: true });
assert.ok(f.trainer);
const arrival = ship(movement(f.game, f.owner), f.owner, 'homeworld:guild', 4, 3);
const homeBefore = army(arrival.after, f.owner, 'homeworld:ixians');
let g = cunning(open(arrival.after, f.owner, f.guild, 'homeworld:guild'), f.owner, f.guild);
const before = reload(g);
g = plans(g, [{ actor: f.owner, action: { type: 'battlePlan', leader: f.trainer, dial: 3, support: 0 } },
{ actor: f.guild, action: { type: 'battlePlan', leader: leader(g, f.guild), dial: 1, support: 1 } }]);
const receipt = quote(g), payment = receipt.payments.find(p => p.player === f.guild)!;
assert.equal(payment.ownPayment, 1); assert.equal(receipt.winner, f.owner);
g = advance(g, s => s.decision?.kind === 'sukRescue'); moneyAtHeldBoundary(before, g, receipt);
const d = g.decision; assert.ok(d?.kind === 'sukRescue');
const choice = d.options.findIndex(o => o.normal === 0 && o.elite === 1 && o.kept?.kind === 'elite'); assert.ok(choice >= 0);
reject(g, f.owner, { type: 'decision', event: d.event, choice, destinations: { 'homeworld:ixians': { normal: 0, elite: 1 } } });
g = step(reload(g), { actor: f.owner, action: { type: 'decision', event: d.event, choice } });
g = advance(g, s => s.decision?.kind === 'ixSubstitution');
const physical = g.lastBattleContext!.sukRescue!.physical!;
assert.equal(physical.source, 'visitor-homeworld'); assert.deepEqual(physical.destinations, {});
assert.deepEqual(physical.saved, { normal: 0, elite: 1 }); assert.deepEqual(physical.tanks, { normal: 0, elite: 2 });
assert.deepEqual(army(g, f.owner, 'homeworld:ixians'), homeBefore, 'The kept rescued Cyborg never returns through an absent companion.');
const pool = army(g, f.owner, 'homeworld:guild'), beforeExchange = reload(g);
const exchange: Action = { type: 'decision', sources: { 'homeworld:guild': 1 }, recover: { 'homeworld:guild': 1 } };
reject(g, f.owner, { ...exchange, sources: { 'homeworld:ixians': 1 } });
g = step(reload(g), { actor: f.owner, action: exchange });
assert.deepEqual(army(g, f.owner, 'homeworld:guild'), { normal: pool.normal - 1, elite: pool.elite + 1 });
assert.deepEqual(army(g, f.owner, 'homeworld:ixians'), homeBefore);
assert.equal(player(g, f.owner).spice, player(beforeExchange, f.owner).spice);
g = finish(g); inventory(g);
});
for (const skills of [false, true]) void test(`single Ixian ${skills ? 'trained' : 'untrained'} original Discovery reveal/free entry and nested losses retain both-pile closing and physical cleanup`, () => {
const f = programme({ native: 'ixians', advanced: true, skills, homeworlds: true, discovery: true, tech: true, strongholds: true });
assert.ok(f.firstArrival && f.reveal && f.entry && f.vote && f.ride);
const entered = army(f.entry.after, f.owner, 'shrine'); assert.deepEqual(entered, { normal: 1, elite: 1 });
assert.deepEqual(f.entry.after.homeworlds!.custody, f.entry.before.homeworlds!.custody);
assert.equal(player(f.entry.after, f.owner).spice, player(f.entry.before, f.owner).spice);
assert.ok(f.entry.before.spiceDiscard[0].length && f.entry.before.spiceDiscard[1].length,
'The actual Advanced Spice sequence uses both piles before the owned entry.');
reject(f.entry.before, f.guild, f.entry.step.action);
reject(f.entry.before, f.owner, { ...f.entry.step.action, event: 'stale-entry' });
policies(f.entry.before, f.owner, a => a.type === 'decision' && a.accept === false, after => {
assert.deepEqual(army(after, f.owner, 'shrine'), { normal: 0, elite: 0 });
assert.deepEqual(player(after, f.owner).forces, player(f.entry!.before, f.owner).forces);
assert.equal(player(after, f.owner).spice, player(f.entry!.before, f.owner).spice);
});
assert.equal(player(f.ride.after, f.fremen).reserves, player(f.ride.before, f.fremen).reserves - 2);
assert.equal(player(f.ride.after, f.fremen).elites!.reserves, player(f.ride.before, f.fremen).elites!.reserves - 1);
assert.equal(player(f.ride.after, f.fremen).spice, player(f.ride.before, f.fremen).spice);
const needed = new Set([f.owner, f.guild]); let g = reload(f.game);
while (needed.size) {
g = advance(g, s => s.phase === 5 && clean(s) && needed.has(s.active!)); const actor = g.active!;
g = ship(g, actor, 'shrine', 3, actor === f.owner ? 2 : 0).after; needed.delete(actor);
if (needed.size) g = step(g, { actor, action: { type: 'endMovement' } });
}
g = cunning(open(g, f.owner, f.guild, 'shrine'), f.owner, f.guild);
const worthless = player(g, f.owner).hand.find(c => c.kind === 'worthless'); assert.ok(worthless);
const before = reload(g), lostTech = ownedTech(g.techTokens, f.guild);
g = plans(g, [{ actor: f.owner, action: { type: 'battlePlan', leader: skills ? f.trainer! : leader(g, f.owner, true), dial: 3, support: 0, weapon: worthless.id } },
{ actor: f.guild, action: { type: 'battlePlan', leader: leader(g, f.guild), dial: 0, support: 0 } }]);
const receipt = quote(g);
if (skills) {
g = advance(g, s => s.decision?.kind === 'sukRescue'); moneyAtHeldBoundary(before, g, receipt);
const d = g.decision; assert.ok(d?.kind === 'sukRescue');
const choice = d.options.findIndex(o => o.normal === 0 && o.elite === 1 && o.kept?.kind === 'elite'); assert.ok(choice >= 0);
g = step(reload(g), { actor: f.owner, action: { type: 'decision', event: d.event, choice } });
}
g = advance(g, s => s.decision?.kind === 'ixSubstitution');
assert.deepEqual(g.pendingIxSubstitution!.losses, { 'shrine:0': skills ? 2 : 3 });
const residual = reload(g), pool = army(g, f.owner, 'shrine');
const exchange: Action = { type: 'decision', sources: { 'shrine:0': 1 }, recover: { 'shrine:0': 1 } };
reject(g, f.owner, { ...exchange, recover: { 'homeworld:ixians': 1 } });
g = step(reload(g), { actor: f.owner, action: exchange });
assert.deepEqual(army(g, f.owner, 'shrine'), { normal: pool.normal - 1, elite: pool.elite + 1 });
assert.equal(player(g, f.owner).elites!.tanks, player(residual, f.owner).elites!.tanks - 1);
assert.equal(player(g, f.owner).tanks, player(residual, f.owner).tanks);
g = advance(g, s => s.decision?.kind === 'battleCards');
for (const token of lostTech) assert.equal(g.techTokens![token].owner, f.guild);
policies(g, f.owner, a => a.type === 'decision' && a.discard !== undefined, (after, action) => {
const discarded = (action.discard as string[]).includes(worthless.id);
assert.equal(player(after, f.owner).hand.some(c => c.id === worthless.id), !discarded);
assert.equal(after.discard.some(c => c.id === worthless.id), discarded);
});
g = step(reload(g), { actor: f.owner, action: { type: 'decision', discard: [worthless.id] } }); g = finish(g);
assert.equal(g.discard.filter(c => c.id === worthless.id).length, 1);
for (const token of lostTech) assert.equal(g.techTokens![token].owner, f.owner);
assert.equal(player(g, f.guild).tanks, player(before, f.guild).tanks + 3);
assert.deepEqual(army(g, f.guild, 'shrine'), { normal: 0, elite: 0 }); inventory(g);
});
for (const skills of [false, true]) void test(`single Tleilaxu ${skills ? 'all14' : 'no-Skills'} actual original matching Face Dancer and Cunning refresh do not manufacture Cyborgs`, () => {
const f = programme({ native: 'tleilaxu', advanced: true, skills, homeworlds: true, discovery: true, tech: true, strongholds: true });
assert.ok(f.entry);
if (skills) {
assert.ok(f.offered);
const foreign = viewGame(f.offered, f.guild).leaderSkills!, pending = structuredClone(foreign.offer!);
assert.ok(pending && foreign.eligibleLeaders.length);
reject(f.offered, f.owner, { type: 'leaderSkill', event: pending.event, skill: pending.cards[0],
leader: foreign.eligibleLeaders[0].id });
}
const arrival = ship(movement(f.game, f.owner), f.owner, 'shrine', 2);
let g = open(arrival.after, f.owner, f.guild, 'shrine');
const winner = f.dancerLeader, nativeSkill = reload(g).leaderSkills?.assignments.find(a => a.owner === f.owner);
assert.ok(player(g, f.owner).faceDancers!.some(c => c.leader === winner && !c.revealed));
const worthless = player(g, f.guild).hand.find(c => c.kind === 'worthless'); assert.ok(worthless);
g = plans(g, [{ actor: f.guild, action: { type: 'battlePlan', leader: winner, dial: 0, support: 0, weapon: worthless.id } },
{ actor: f.owner, action: { type: 'battlePlan', leader: leader(g, f.owner), dial: 0, support: 0 } }]);
assert.equal(quote(g).winner, f.guild);
g = advance(g, s => s.decision?.kind === 'battleCards');
assert.equal(g.techTokens!.axlotl.owner, f.owner);
g = step(reload(g), { actor: f.guild, action: { type: 'decision', discard: [worthless.id] } });
g = advance(g, s => s.decision?.kind === 'faceDance');
assert.equal(g.techTokens!.axlotl.owner, f.guild);
assert.equal(g.discard.filter(c => c.id === worthless.id).length, 1);
const before = reload(g), survivor = army(g, f.guild, 'shrine'), nativeHome = army(g, f.owner, 'homeworld:tleilaxu');
const dance: Action = { type: 'decision', reveal: true, sources: { reserves: 1 }, sector: 0 };
reject(g, f.guild, dance); reject(g, f.owner, { ...dance, sources: { reserves: survivor.normal + 1 } });
reject(g, f.owner, { ...dance, sources: { 'homeworld:ixians': 1 } });
policies(g, f.owner, a => a.type === 'decision' && a.reveal !== undefined, (after, action) => {
const count = action.reveal ? Object.values((action.sources ?? {}) as Record<string, number>).reduce((n, f) => n + f, 0) : 0;
assert.equal(player(after, f.guild).leaders.find(l => l.id === winner)!.dead, !!action.reveal);
assert.equal(player(after, f.owner).faceDancers!.find(c => c.leader === winner)!.revealed, !!action.reveal);
assert.deepEqual(army(after, f.guild, 'shrine'), action.reveal ? { normal: 0, elite: 0 } : survivor);
assert.equal(army(after, f.owner, 'shrine').normal, count);
assert.deepEqual(after.techTokens, before.techTokens);
assert.equal(player(after, f.owner).spice, player(before, f.owner).spice);
});
g = step(reload(g), { actor: f.owner, action: dance });
assert.deepEqual(army(g, f.guild, 'shrine'), { normal: 0, elite: 0 });
assert.equal(player(g, f.guild).reserves, player(before, f.guild).reserves + survivor.normal);
assert.deepEqual(army(g, f.owner, 'shrine'), { normal: 1, elite: 0 });
assert.deepEqual(army(g, f.owner, 'homeworld:tleilaxu'), { normal: nativeHome.normal - 1, elite: 0 });
assert.deepEqual(g.techTokens, before.techTokens);
if (skills) {
assert.equal(g.leaderSkills!.assignments.some(a => a.leader === winner), false);
assert.equal(g.leaderSkills!.deck.filter(s => s === 'suk-graduate').length, 1);
const observer = g.leaderSkills!.assignments.find(a => a.owner === f.owner);
assert.equal(observer?.leader, nativeSkill?.leader);
assert.equal(observer?.skill, nativeSkill?.skill, 'Foreign disc death cannot retire the native observer’s training.');
}
reject(g, f.owner, dance);
const offer = viewGame(g, f.owner).nexusTleilaxu?.cunning; assert.ok(offer && !offer.blocked);
const refresh = nexusFaceDancersAction(viewGame(g, f.owner), offer.event); assert.ok(refresh);
const reserve = [...g.traitorReserve!], stock = structuredClone(player(g, f.owner).faceDancers!);
const untouched = stock.filter(c => !c.revealed), allowance = player(g, f.owner).faceDancerReplacedTurn;
reject(g, f.guild, refresh); reject(g, f.owner, { ...refresh, event: 'old-refresh' });
reject(g, f.owner, { ...refresh, leaders: [winner] });
policies(g, f.owner, a => a.type === 'nexusFaceDancers', after => {
assert.deepEqual(player(after, f.owner).faceDancers!.filter(c => untouched.some(old => old.leader === c.leader)), untouched);
assert.deepEqual(player(after, f.owner).faceDancers!.filter(c => !untouched.some(old => old.leader === c.leader)), [{ leader: reserve[0], revealed: false }]);
assert.deepEqual([...after.traitorReserve!].sort(), [...reserve.slice(1), winner].sort());
assert.equal(player(after, f.owner).faceDancerReplacedTurn, allowance);
assert.equal(after.nexusCards!.cards!.hands[f.owner], null);
assert.deepEqual(after.techTokens, g.techTokens);
assert.deepEqual(army(after, f.owner, 'shrine'), { normal: 1, elite: 0 });
assert.equal(player(after, f.owner).spice, player(g, f.owner).spice);
});
g = step(reload(g), { actor: f.owner, action: refresh }); reject(g, f.owner, refresh); g = finish(g); inventory(g);
});
for (const skills of [false, true]) void test(`single Tleilaxu ${skills ? 'trained' : 'untrained'} native paid support, real leader death and own discounted revival preserve original disc identity`, () => {
const f = programme({ native: 'tleilaxu', advanced: true, skills, homeworlds: true, tech: true,
startingKinds: ['projectile', 'projectile', 'worthless'] });
const arrival = ship(movement(f.game, f.guild), f.guild, 'homeworld:tleilaxu', 3);
let g = open(arrival.after, f.owner, f.guild, 'homeworld:tleilaxu');
const own = skills ? g.leaderSkills!.assignments.find(a => a.owner === f.owner)!.leader : leader(g, f.owner, true);
const foreign = leader(g, f.guild), nativeAssignment = g.leaderSkills?.assignments.find(a => a.owner === f.owner);
const foreignAssignment = structuredClone(g.leaderSkills?.assignments.find(a => a.owner === f.guild));
const ownWeapon = player(g, f.owner).hand.find(c => c.kind === 'projectile')!.id;
const enemyWeapon = player(g, f.guild).hand.find(c => c.kind === 'projectile')!.id;
const before = reload(g);
g = plans(g, [{ actor: f.owner, action: { type: 'battlePlan', leader: own, dial: 1, support: 1, weapon: ownWeapon } },
{ actor: f.guild, action: { type: 'battlePlan', leader: foreign, dial: 0, support: 0, weapon: enemyWeapon } }]);
const receipt = quote(g); assert.equal(receipt.payments.find(p => p.player === f.owner)!.ownPayment, 1);
assert.equal(player(g, f.owner).spice, player(before, f.owner).spice, 'Plans do not prepay even with native free strength.');
g = advance(g, s => s.decision?.kind === 'battleCards'); moneyAtHeldBoundary(before, g, receipt);
assert.equal(player(g, f.owner).leaders.find(l => l.id === own)!.dead, true);
assert.equal(player(g, f.guild).leaders.find(l => l.id === foreign)!.dead, true);
assert.equal(player(g, f.owner).tanks, player(before, f.owner).tanks + 1);
assert.equal(player(g, f.guild).tanks, player(before, f.guild).tanks + 3);
if (skills) {
assert.equal(g.leaderSkills!.assignments.some(a => a.leader === own), false);
assert.equal(g.leaderSkills!.deck.filter(s => s === nativeAssignment!.skill).length, 1);
const foreignTraining = g.leaderSkills!.assignments.find(a => a.owner === f.guild);
assert.equal(foreignTraining?.leader, foreignAssignment?.leader);
assert.equal(foreignTraining?.skill, foreignAssignment?.skill);
}
g = step(reload(g), { actor: f.owner, action: { type: 'decision', discard: [ownWeapon] } }); g = finish(g);
assert.equal(g.discard.filter(c => c.id === ownWeapon).length, 1);
g = advance(g, s => s.turn === 3 && s.phase === 4 && clean(s));
const revivalBefore = reload(g), offer = viewGame(g, f.owner).revival.leaders.find(l => l.id === own); assert.ok(offer && offer.affordable);
const revive: Action = { type: 'reviveLeader', leader: own };
reject(g, f.guild, revive); reject(g, f.owner, { ...revive, leader: f.dancerLeader });
const revived = (state: Game): Game => advance(state, s => !s.pendingRevival && !s.response && !s.phaseOpening &&
(skills ? !!s.leaderSkills?.offers[f.owner] : !s.decision));
policies(g, f.owner, a => a.type === 'reviveLeader' && a.leader === own, after => {
const resolved = revived(after);
assert.equal(player(resolved, f.owner).leaders.find(l => l.id === own)!.dead, false);
assert.equal(player(resolved, f.owner).spice, player(revivalBefore, f.owner).spice - offer.cost);
assert.deepEqual(resolved.homeworlds!.custody, revivalBefore.homeworlds!.custody);
assert.equal(player(resolved, f.guild).leaders.some(l => l.id === own), false);
});
g = revived(step(reload(g), { actor: f.owner, action: revive })); reject(g, f.owner, revive);
assert.equal(player(g, f.owner).spice, player(revivalBefore, f.owner).spice - offer.cost);
if (skills) {
const acceptance = viewGame(g, f.owner).leaderSkills!.offer!;
const draw: Action = { type: 'leaderSkill', event: acceptance.event, mode: 'draw' };
reject(g, f.guild, draw); reject(g, f.owner, { ...draw, event: 'old-revival' });
const beforeDraw = reload(g);
policies(g, f.owner, a => a.type === 'leaderSkill' && a.mode === 'draw', after => {
assert.equal(after.leaderSkills!.offers[f.owner].cards.length, 2);
assert.equal(after.leaderSkills!.deck.length, beforeDraw.leaderSkills!.deck.length - 2);
assert.equal(player(after, f.owner).leaders.find(l => l.id === own)!.dead, false);
assert.equal(player(after, f.owner).spice, player(beforeDraw, f.owner).spice);
});
g = step(g, { actor: f.owner, action: draw }); reject(g, f.owner, draw);
const training = viewGame(g, f.owner).leaderSkills!; assert.equal(training.offer!.leader, own);
const pending = reload(g).leaderSkills!.offers[f.owner];
const skill = training.offer!.cards.find(s => !training.unavailableSkills?.[s]); assert.ok(skill);
const train: Action = { type: 'leaderSkill', event: training.offer!.event, leader: own, skill };
reject(g, f.guild, train); reject(g, f.owner, { ...train, leader: foreign });
reject(g, f.owner, { ...train, event: 'former-native-training' });
assert.deepEqual(g.leaderSkills!.offers[f.owner], pending, 'A foreign or stale training attempt cannot consume the owned revival offer.');
policies(g, f.owner, a => a.type === 'leaderSkill', (after, action) => {
assert.equal(after.leaderSkills!.assignments.find(a => a.owner === f.owner)!.leader, own);
assert.equal(after.leaderSkills!.assignments.find(a => a.owner === f.owner)!.skill, action.skill);
assert.equal(player(after, f.owner).spice, player(g, f.owner).spice);
const foreignTraining = after.leaderSkills!.assignments.find(a => a.owner === f.guild);
assert.equal(foreignTraining?.leader, foreignAssignment?.leader);
assert.equal(foreignTraining?.skill, foreignAssignment?.skill);
});
g = step(reload(g), { actor: f.owner, action: train }); reject(g, f.owner, train);
assert.equal(g.leaderSkills!.assignments.find(a => a.owner === f.owner)!.leader, own);
}
inventory(g);
});
void test('single native Tleilaxu cannot replace a real matching Guild winner in a foreign Homeworld battle', () => {
const f = programme({ native: 'tleilaxu', advanced: false, skills: false, homeworlds: true, tech: true });
assert.ok(player(f.afterSetup, f.owner).faceDancers!.some(c => c.leader === f.dancerLeader && !c.revealed));
const arrival = ship(movement(f.game, f.owner), f.owner, 'homeworld:guild', 1);
let g = open(arrival.after, f.owner, f.guild, 'homeworld:guild');
g = plans(g, [{ actor: f.guild, action: { type: 'battlePlan', leader: f.dancerLeader, dial: 0, support: 0 } },
{ actor: f.owner, action: { type: 'battlePlan', leader: leader(g, f.owner), dial: 0, support: 0 } }]);
assert.equal(quote(g).winner, f.guild); g = finish(g);
const physical = army(g, f.guild, 'homeworld:guild');
reject(g, f.owner, { type: 'decision', reveal: true, sources: { reserves: 1 }, sector: 0 });
assert.equal(player(g, f.owner).faceDancers!.find(c => c.leader === f.dancerLeader)!.revealed, false);
assert.equal(player(g, f.guild).leaders.find(l => l.id === f.dancerLeader)!.dead, false);
assert.deepEqual(army(g, f.guild, 'homeworld:guild'), physical);
assert.equal(player(g, f.owner).tanks, 1); inventory(g);
});
+284
View File
@@ -0,0 +1,284 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame } from '../game/engine';
import type { Action, Game } from '../game/engine';
import { DIFFICULTIES } from '../game/bot-profiles';
import { ownedTech } from '../game/tech-tokens';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
import { CHOAM_AUDITOR_ID } from '../game/choam-auditor';
import { nativeHomeworldArmy } from './fixture-homeworld-native-e1e2-modules';
import {
advanceSingleE2 as advance, createSingleE2Programme as programme,
finishSingleE2Battle as finishBattle, openSingleE2Battle as openBattle,
revealSingleE2Plans as revealPlans, settleSingleE2 as settle,
shipSingleE2 as ship, singleE2Clean as clean, singleE2Cunning as cunning,
singleE2Inventory as inventory, singleE2Leader as leader, singleE2Player as player,
singleE2Policy as policy, singleE2Reload as reload, singleE2Step as step,
} from './fixture-single-nexus-e2';
import type { SingleNexusE2Programme } from './fixture-single-nexus-e2';
/** Every state begins at an original initializer. No physical force, held card,
* purse, phase, training identity, alliance or outcome is assigned by a test. */
function reject(game: Game, actor: string, action: Action): void {
const before = reload(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before, 'Invalid consumers do not debit physical inventory or payments.');
}
function policies(game: Game, actor: string, select: (action: Action) => boolean,
outcome: (after: Game, action: Action) => void): void {
for (const difficulty of DIFFICULTIES) {
const action = policy(game, actor, difficulty).find(select);
assert.ok(action, `${difficulty} must consume its genuine owned window.`);
const after = step(reload(game), { actor, action });
outcome(after, action); inventory(after);
}
}
function movement(game: Game, actor: string): Game {
return advance(game, g => g.turn === 2 && g.phase === 5 && g.active === actor && clean(g));
}
function collectionIncome(game: Game, actor: string): number {
if (game.phase !== 7 || !clean(game)) return 0;
const row = quoteSpiceCollection(game).receipts.find(r => r.player === actor);
return (row?.strongholds ?? 0) + (row?.collected ?? 0);
}
function originalClosing(f: SingleNexusE2Programme): void {
assert.equal(player(f.alliance, 'guild').ally, 'fremen');
assert.equal(player(f.alliance, 'fremen').ally, 'guild');
assert.ok(!player(f.alliance, f.owner).ally);
assert.equal(f.draw.after.nexusCards!.cards!.hands[f.owner], player(f.draw.after, f.owner).faction);
assert.deepEqual(player(f.draw.after, f.owner).forces, player(f.draw.before, f.owner).forces);
policies(f.draw.before, f.owner, a => a.type === 'nexusCardChoice', after => {
assert.equal(after.nexusCards!.cards!.hands[f.owner], player(after, f.owner).faction);
});
if (f.entry) {
assert.equal(player(f.entry.after, 'guild').forces['shrine:0'], 3);
assert.equal(player(f.entry.after, 'guild').spice, player(f.entry.before, 'guild').spice);
assert.deepEqual(f.entry.after.homeworlds, f.entry.before.homeworlds);
policies(f.entry.before, 'guild', a => a.type === 'decision' && a.accept === false, after => {
assert.deepEqual(player(after, 'guild').forces, player(f.entry!.before, 'guild').forces);
assert.equal(player(after, 'guild').spice, player(f.entry!.before, 'guild').spice);
});
assert.ok(f.ride && f.vote);
assert.equal(player(f.ride.after, 'fremen').reserves, player(f.ride.before, 'fremen').reserves - 2);
assert.equal(player(f.ride.after, 'fremen').forces['polar_sink:0'], (player(f.ride.before, 'fremen').forces['polar_sink:0'] ?? 0) + 2);
assert.equal(player(f.ride.after, 'fremen').spice, player(f.ride.before, 'fremen').spice);
}
}
void test('single CHOAM Special fuels native Cunning once; visitor Suk delays only the other payer support income and original winner Tech', () => {
const f = programme({ native: 'choam', advanced: true, skills: true, homeworlds: true,
discovery: false, tech: true, strongholds: true, skill: 'suk-graduate', fuelKind: 'special' });
originalClosing(f); assert.ok(f.trainer && f.offered);
const offered = viewGame(f.offered, f.owner).leaderSkills!;
assert.ok(offered.offer!.cards.includes('suk-graduate'));
assert.ok(player(f.offered, f.owner).leaders.some(l => l.id === CHOAM_AUDITOR_ID));
reject(f.offered, f.owner, { type: 'leaderSkill', event: offered.offer!.event,
skill: 'suk-graduate', leader: CHOAM_AUDITOR_ID });
let game = movement(f.game, f.owner);
const fuel = player(game, f.owner).hand.find(c => c.kind === 'special' && c.effect !== 'karama'); assert.ok(fuel);
const request = cunning(game, f.owner, fuel.id), beforeFuel = reload(game);
reject(game, f.owner, { ...request, nexus: 'obsolete-choam' });
reject(game, 'guild', request);
reject(game, f.owner, { ...request, card: player(game, 'guild').hand[0].id });
game = settle(step(game, { actor: f.owner, action: request }));
assert.equal(player(game, f.owner).spice, player(beforeFuel, f.owner).spice);
assert.equal(player(game, f.owner).hand.some(c => c.id === fuel.id), false);
assert.equal(game.discard.filter(c => c.id === fuel.id).length, 1);
assert.equal(game.nexusCards!.cards!.hands[f.owner], null);
assert.equal(game.choamMovement!.bonus, (beforeFuel.choamMovement?.bonus ?? 0) + 1);
reject(game, f.owner, request);
game = ship(game, f.owner, 'homeworld:guild', 3);
game = openBattle(game, f.owner, 'guild', 'homeworld:guild', true);
const beforePlans = reload(game), lostTech = ownedTech(game.techTokens, 'guild');
game = revealPlans(game, [
{ actor: f.owner, action: { type: 'battlePlan', leader: f.trainer, dial: 2, support: 2 } },
{ actor: 'guild', action: { type: 'battlePlan', leader: leader(game, 'guild'), dial: 2, support: 2 } },
]);
assert.equal(player(game, f.owner).spice, player(beforePlans, f.owner).spice);
game = advance(game, g => g.decision?.kind === 'sukRescue');
assert.equal(player(game, f.owner).spice, player(beforePlans, f.owner).spice - 2);
assert.equal(player(game, 'guild').spice, player(beforePlans, 'guild').spice - 2);
assert.deepEqual(game.pendingChoamBattleIncome, { owner: f.owner, amount: 1 },
'CHOAM excludes its own two support payments; only the opponent pays pending native income.');
for (const token of lostTech) assert.equal(game.techTokens![token].owner, 'guild');
const d = game.decision; assert.ok(d?.kind === 'sukRescue');
const home = nativeHomeworldArmy(game, f.owner, 'homeworld:choam');
const source = nativeHomeworldArmy(game, f.owner, 'homeworld:guild'), beforeRescue = reload(game);
policies(game, f.owner, a => a.type === 'decision' && a.choice !== undefined, (after, action) => {
const choice = d.options[Number(action.choice)], saved = choice.normal + choice.elite;
assert.equal(player(after, f.owner).tanks, player(beforeRescue, f.owner).tanks + 2 - saved);
assert.equal(player(after, f.owner).spice, player(beforeRescue, f.owner).spice +
(after.pendingChoamBattleIncome ? 0 : 1) + collectionIncome(after, f.owner),
'Rescue never refunds support; the original native income may settle only after its physical rescue.');
const kept = choice.kept?.kind === 'normal' ? 1 : 0;
assert.deepEqual(nativeHomeworldArmy(after, f.owner, 'homeworld:guild'), { normal: source.normal - 2 + kept, elite: 0 });
assert.deepEqual(nativeHomeworldArmy(after, f.owner, 'homeworld:choam'), { normal: home.normal + choice.normal - kept, elite: 0 });
});
const choice = d.options.findIndex(o => o.normal === 1 && o.elite === 0 && o.kept?.kind === 'normal'); assert.ok(choice >= 0);
const rescue: Action = { type: 'decision', event: d.event, choice };
reject(game, 'guild', rescue); reject(game, f.owner, { ...rescue, event: 'obsolete-rescue' });
game = step(reload(game), { actor: f.owner, action: rescue });
game = finishBattle(game);
assert.equal(game.lastBattleContext!.winner, f.owner);
assert.equal(game.lastBattleContext!.sukRescue!.physical!.source, 'visitor-homeworld');
assert.equal(player(game, f.owner).tanks, player(beforePlans, f.owner).tanks + 1);
assert.deepEqual(nativeHomeworldArmy(game, f.owner, 'homeworld:guild'), { normal: 2, elite: 0 });
assert.equal(player(game, f.owner).spice, player(beforePlans, f.owner).spice - 2 + 1 + collectionIncome(game, f.owner));
for (const token of lostTech) assert.equal(game.techTokens![token].owner, f.owner);
assert.equal(game.discard.filter(c => c.id === fuel.id).length, 1);
reject(game, f.owner, rescue); inventory(game);
});
void test('single CHOAM Shield is distinct any-Treachery Cunning fuel without Skills; real Maker entry and nested support settle once', () => {
const f = programme({ native: 'choam', advanced: true, skills: false, homeworlds: false,
discovery: true, tech: true, strongholds: false, fuelKind: 'shield' });
originalClosing(f);
let game = movement(f.game, f.owner);
const fuel = player(game, f.owner).hand.find(c => c.kind === 'shield'); assert.ok(fuel);
const beforeFuel = reload(game), action = cunning(game, f.owner, fuel.id);
game = settle(step(game, { actor: f.owner, action }));
assert.equal(game.discard.filter(c => c.id === fuel.id).length, 1);
assert.equal(player(game, f.owner).hand.some(c => c.id === fuel.id), false);
assert.equal(player(game, f.owner).spice, player(beforeFuel, f.owner).spice);
assert.equal(game.choamMovement!.bonus, 1); reject(game, f.owner, action);
game = ship(game, f.owner, 'shrine', 3);
game = openBattle(game, f.owner, 'guild', 'shrine');
const beforePlans = reload(game), lostTech = ownedTech(game.techTokens, 'guild');
game = revealPlans(game, [
{ actor: f.owner, action: { type: 'battlePlan', leader: leader(game, f.owner, true), dial: 2, support: 2 } },
{ actor: 'guild', action: { type: 'battlePlan', leader: leader(game, 'guild'), dial: 2, support: 2 } },
]);
game = finishBattle(game);
assert.equal(game.lastBattleContext!.winner, f.owner);
assert.equal(player(game, f.owner).forces['shrine:0'], 1);
assert.equal(player(game, f.owner).tanks, player(beforePlans, f.owner).tanks + 2);
assert.equal(player(game, 'guild').tanks, player(beforePlans, 'guild').tanks + 3);
assert.equal(player(game, f.owner).spice, player(beforePlans, f.owner).spice - 2 + 1 + collectionIncome(game, f.owner));
assert.equal(player(game, 'guild').spice, player(beforePlans, 'guild').spice - 2 + collectionIncome(game, 'guild'));
for (const token of lostTech) assert.equal(game.techTokens![token].owner, f.owner);
inventory(game);
});
void test('Basic single Richese without Skills signs one nested invoice, exposes five before three and resolves real card custody without a CHOAM income owner', () => {
const f = programme({ native: 'richese', advanced: false, skills: false,
homeworlds: false, discovery: true, tech: true, strongholds: false });
originalClosing(f); assert.ok(f.firstMarker);
assert.equal(player(f.firstMarker.after, f.owner).spice, player(f.firstMarker.before, f.owner).spice - 1);
assert.equal(player(f.firstMarker.after, f.owner).reserves, player(f.firstMarker.before, f.owner).reserves);
let game = movement(f.game, f.owner), before = reload(game);
const request = cunning(game, f.owner);
reject(game, f.owner, { ...request, nexus: 'obsolete-richese' });
reject(game, f.owner, { ...request, revealedToken: request.noField });
reject(game, f.owner, { ...request, smuggler: true });
reject(game, f.owner, { ...request, homeworldSources: { 'homeworld:richese': { normal: 5, elite: 0 } } });
game = step(game, { actor: f.owner, action: request });
game = settle(reload(game));
assert.equal(player(game, f.owner).spice, player(before, f.owner).spice - 1);
assert.equal(player(game, 'guild').spice, player(before, 'guild').spice + 1);
assert.equal(player(game, f.owner).forces['shrine:0'], 5);
assert.equal(player(game, f.owner).reserves, player(before, f.owner).reserves - 5);
assert.equal(player(game, f.owner).noField!.deployed!.tokenId, request.noField);
assert.equal(game.nexusRicheseCunningHistory!.at(-1)!.stage, 'shipped');
assert.equal(game.nexusCards!.cards!.discard.filter(c => c === 'richese').length, 1);
reject(game, f.owner, request);
before = reload(game);
const reveal: Action = { type: 'revealNoField', event: player(game, f.owner).noFieldEvent, token: request.noField };
reject(game, f.owner, { ...reveal, event: 'obsolete-token' });
game = step(reload(game), { actor: f.owner, action: reveal });
assert.equal(player(game, f.owner).forces['shrine:0'], 8);
assert.equal(player(game, f.owner).reserves, player(before, f.owner).reserves - 3);
assert.equal(player(game, f.owner).spice, player(before, f.owner).spice);
assert.equal(player(game, 'guild').spice, player(before, 'guild').spice);
game = openBattle(game, f.owner, 'guild', 'shrine');
const held = player(game, f.owner).hand.find(c => c.kind === 'worthless'); assert.ok(held);
const beforePlans = reload(game), lostTech = ownedTech(game.techTokens, 'guild');
game = revealPlans(game, [
{ actor: f.owner, action: { type: 'battlePlan', leader: leader(game, f.owner, true), dial: 2, support: 0, weapon: held.id } },
{ actor: 'guild', action: { type: 'battlePlan', leader: leader(game, 'guild'), dial: 0, support: 0 } },
]);
game = advance(game, g => g.decision?.kind === 'battleCards');
assert.equal(player(game, f.owner).spice, player(beforePlans, f.owner).spice);
for (const token of lostTech) assert.equal(game.techTokens![token].owner, 'guild');
const cleanup = reload(game);
policies(game, f.owner, a => a.type === 'decision' && a.discard !== undefined, (after, action) => {
const discarded = action.discard as string[];
assert.equal(player(after, f.owner).hand.some(c => c.id === held.id), !discarded.includes(held.id));
assert.equal(after.discard.some(c => c.id === held.id), discarded.includes(held.id));
assert.equal(player(after, f.owner).spice, player(cleanup, f.owner).spice);
});
reject(game, 'guild', { type: 'decision', discard: [held.id] });
game = step(reload(game), { actor: f.owner, action: { type: 'decision', discard: [held.id] } });
game = finishBattle(game);
assert.equal(game.lastBattleContext!.winner, f.owner);
assert.equal(player(game, f.owner).forces['shrine:0'], 6);
assert.equal(player(game, f.owner).tanks, player(beforePlans, f.owner).tanks + 2);
assert.equal(game.discard.filter(c => c.id === held.id).length, 1);
assert.equal(player(game, f.owner).hand.some(c => c.id === held.id), false);
assert.equal(player(game, f.owner).spice, player(beforePlans, f.owner).spice + collectionIncome(game, f.owner));
for (const token of lostTech) assert.equal(game.techTokens![token].owner, f.owner);
inventory(game);
});
void test('Advanced single Richese original native supply funds a real nested pair; Suk rescue and held-card cleanup cannot repeat support or invent companion income', () => {
const f = programme({ native: 'richese', advanced: true, skills: true, homeworlds: true,
discovery: true, tech: true, strongholds: true, skill: 'suk-graduate' });
originalClosing(f); assert.ok(f.trainer);
let game = movement(f.game, f.owner), before = reload(game);
const home = nativeHomeworldArmy(game, f.owner, 'homeworld:richese'), request = cunning(game, f.owner);
assert.ok(home.normal >= 8, 'Only the actual remaining native supply can fund both markers.');
reject(game, f.owner, { ...request, source: 'homeworld:guild' });
reject(game, f.owner, { ...request, smugglerCompanion: true });
reject(game, 'guild', request);
game = settle(step(game, { actor: f.owner, action: request }));
assert.equal(player(game, f.owner).spice, player(before, f.owner).spice - 1);
assert.equal(player(game, 'guild').spice, player(before, 'guild').spice + 1);
assert.deepEqual(nativeHomeworldArmy(game, f.owner, 'homeworld:richese'), { normal: home.normal - 5, elite: 0 });
assert.equal(player(game, f.owner).forces['shrine:0'], 5);
before = reload(game);
game = step(reload(game), { actor: f.owner, action: { type: 'revealNoField',
event: player(game, f.owner).noFieldEvent, token: request.noField } });
assert.equal(player(game, f.owner).forces['shrine:0'], 8);
assert.deepEqual(nativeHomeworldArmy(game, f.owner, 'homeworld:richese'), { normal: home.normal - 8, elite: 0 });
assert.equal(player(game, f.owner).spice, player(before, f.owner).spice);
game = openBattle(game, f.owner, 'guild', 'shrine', true);
const held = player(game, f.owner).hand.find(c => c.kind === 'worthless'); assert.ok(held);
const beforePlans = reload(game), lostTech = ownedTech(game.techTokens, 'guild');
game = revealPlans(game, [
{ actor: f.owner, action: { type: 'battlePlan', leader: f.trainer, dial: 2, support: 2, weapon: held.id } },
{ actor: 'guild', action: { type: 'battlePlan', leader: leader(game, 'guild'), dial: 2, support: 2 } },
]);
assert.equal(player(game, f.owner).spice, player(beforePlans, f.owner).spice);
game = advance(game, g => g.decision?.kind === 'sukRescue');
assert.equal(player(game, f.owner).spice, player(beforePlans, f.owner).spice - 2);
assert.equal(player(game, 'guild').spice, player(beforePlans, 'guild').spice - 2);
const d = game.decision; assert.ok(d?.kind === 'sukRescue');
const nativeBeforeRescue = nativeHomeworldArmy(game, f.owner, 'homeworld:richese'), beforeRescue = reload(game);
policies(game, f.owner, a => a.type === 'decision' && a.choice !== undefined, (after, action) => {
const option = d.options[Number(action.choice)], kept = option.kept?.kind === 'normal' ? 1 : 0;
assert.equal(player(after, f.owner).forces['shrine:0'], 6 + kept);
assert.equal(player(after, f.owner).tanks, player(beforeRescue, f.owner).tanks + 2 - option.normal - option.elite);
assert.deepEqual(nativeHomeworldArmy(after, f.owner, 'homeworld:richese'), { normal: nativeBeforeRescue.normal + option.normal - kept, elite: 0 });
assert.equal(player(after, f.owner).spice, player(beforeRescue, f.owner).spice);
});
const choice = d.options.findIndex(o => o.normal === 2 && o.elite === 0 && o.kept?.kind === 'normal'); assert.ok(choice >= 0);
const rescue: Action = { type: 'decision', event: d.event, choice };
reject(game, f.owner, { ...rescue, event: 'obsolete-suk' });
reject(game, f.owner, { ...rescue, destinations: { 'homeworld:guild': { normal: 2, elite: 0 } } });
game = step(reload(game), { actor: f.owner, action: rescue });
game = advance(game, g => g.decision?.kind === 'battleCards');
assert.equal(player(game, f.owner).spice, player(beforePlans, f.owner).spice - 2);
assert.equal(player(game, 'guild').spice, player(beforePlans, 'guild').spice - 2);
assert.equal(player(game, f.owner).forces['shrine:0'], 7);
assert.deepEqual(nativeHomeworldArmy(game, f.owner, 'homeworld:richese'), { normal: nativeBeforeRescue.normal + 1, elite: 0 });
for (const token of lostTech) assert.equal(game.techTokens![token].owner, 'guild');
reject(game, 'guild', { type: 'decision', discard: [held.id] });
game = finishBattle(step(reload(game), { actor: f.owner, action: { type: 'decision', discard: [held.id] } }));
assert.equal(game.lastBattleContext!.winner, f.owner);
assert.equal(player(game, f.owner).tanks, player(beforePlans, f.owner).tanks);
assert.equal(player(game, 'guild').tanks, player(beforePlans, 'guild').tanks + 3);
assert.equal(game.discard.filter(c => c.id === held.id).length, 1);
assert.equal(player(game, f.owner).spice, player(beforePlans, f.owner).spice - 2 + collectionIncome(game, f.owner));
assert.equal(player(game, 'guild').spice, player(beforePlans, 'guild').spice - 2 + collectionIncome(game, 'guild'));
for (const token of lostTech) assert.equal(game.techTokens![token].owner, f.owner);
reject(game, f.owner, request); inventory(game);
});
+43 -14
View File
@@ -4,7 +4,7 @@ import { readFileSync, statSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseArgs } from 'node:util';
import { classicNexusModulesProfile, pairedNexusModulesProfile, standaloneE3NexusProfile } from '../game/nexus-module-profile';
import { classicNexusModulesProfile, pairedNexusModulesProfile, singleE1E2NexusProfile, standaloneE3NexusProfile } from '../game/nexus-module-profile';
import { nativeFactionTechProfile } from '../game/faction-module-profile';
import { classicDiscoveryLeaderSkillsProfile } from '../game/discovery-module-profile';
import { botActions } from '../game/bots';
@@ -87,6 +87,16 @@ const E3_NEXUS_MODULE_PROFILES = [
'e3-homeworld-nexus-discovery', 'e3-homeworld-nexus-discovery-tech',
'e3-homeworld-nexus-discovery-stronghold', 'e3-homeworld-nexus-discovery-stronghold-tech',
] as const;
const SINGLE_NATIVE_NEXUS_MODULE_PROFILES = [
'single-nexus', 'single-nexus-tech', 'single-nexus-stronghold', 'single-nexus-stronghold-tech',
'single-nexus-skills', 'single-nexus-skills-tech', 'single-nexus-skills-stronghold', 'single-nexus-skills-stronghold-tech',
'single-discovery-nexus', 'single-discovery-nexus-tech', 'single-discovery-nexus-stronghold', 'single-discovery-nexus-stronghold-tech',
'single-discovery-nexus-skills', 'single-discovery-nexus-skills-tech', 'single-discovery-nexus-skills-stronghold', 'single-discovery-nexus-skills-stronghold-tech',
'single-homeworld-nexus', 'single-homeworld-nexus-tech', 'single-homeworld-nexus-stronghold', 'single-homeworld-nexus-stronghold-tech',
'single-homeworld-nexus-skills', 'single-homeworld-nexus-skills-tech', 'single-homeworld-nexus-skills-stronghold', 'single-homeworld-nexus-skills-stronghold-tech',
'single-homeworld-nexus-discovery', 'single-homeworld-nexus-discovery-tech', 'single-homeworld-nexus-discovery-stronghold', 'single-homeworld-nexus-discovery-stronghold-tech',
'single-homeworld-nexus-discovery-skills', 'single-homeworld-nexus-discovery-skills-tech', 'single-homeworld-nexus-discovery-skills-stronghold', 'single-homeworld-nexus-discovery-skills-stronghold-tech',
] as const;
type Profile = 'base' | 'choam' | 'ecaz' | 'ecaz-treachery' | 'ecaz-occupy' | 'moritani-assassinate' | 'stronghold-factions' | 'combined' | 'combined-nexus' | 'combined-homeworld-nexus' | 'homeworld' | 'nexus' | 'homeworld-nexus' | 'choam-roster' | 'ecaz-roster' | 'ix-roster' | 'choam-nexus' | 'ecaz-nexus' | 'ix-nexus' | 'moritani-skills' | 'tleilaxu-skills' | 'ix-skills' | 'choam-skills' | 'richese-skills' | 'ecaz-skills' | 'skills-tech' | 'skills-stronghold' | 'skills-stronghold-tech'
| 'factions-tech' | 'stronghold-factions-tech' | 'nexus-tech' | 'nexus-stronghold' | 'nexus-stronghold-tech'
| 'paired-nexus-tech' | 'paired-nexus-stronghold' | 'paired-nexus-stronghold-tech' | 'nexus-skills' | 'paired-nexus-skills'
@@ -104,7 +114,8 @@ type Profile = 'base' | 'choam' | 'ecaz' | 'ecaz-treachery' | 'ecaz-occupy' | 'm
| (typeof HOMEWORLD_MODULE_PROFILES)[number]
| (typeof HOMEWORLD_NEXUS_MODULE_PROFILES)[number]
| (typeof HOMEWORLD_NATIVE_NEXUS_MODULE_PROFILES)[number]
| (typeof E3_NEXUS_MODULE_PROFILES)[number];
| (typeof E3_NEXUS_MODULE_PROFILES)[number]
| (typeof SINGLE_NATIVE_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' ||
@@ -127,12 +138,14 @@ 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 singleNativeNexusSampleProfile = (profile: string) =>
(SINGLE_NATIVE_NEXUS_MODULE_PROFILES as readonly string[]).includes(profile);
const e3NexusSampleProfile = (profile: string) =>
(E3_NEXUS_MODULE_PROFILES as readonly string[]).includes(profile);
const nativeHomeworldNexusSampleProfile = (profile: string) =>
(HOMEWORLD_NATIVE_NEXUS_MODULE_PROFILES as readonly string[]).includes(profile);
const homeworldNexusSampleProfile = (profile: string) =>
(e3NexusSampleProfile(profile) && profile.includes('-homeworld-')) ||
((e3NexusSampleProfile(profile) || singleNativeNexusSampleProfile(profile)) && profile.includes('-homeworld-')) ||
(HOMEWORLD_NEXUS_MODULE_PROFILES as readonly string[]).includes(profile) || nativeHomeworldNexusSampleProfile(profile);
const homeworldModuleSampleProfile = (profile: string) =>
(HOMEWORLD_MODULE_PROFILES as readonly string[]).includes(profile) || homeworldNexusSampleProfile(profile);
@@ -280,16 +293,16 @@ const CLASSIC_NEXUS_MODULE_SCENARIOS: readonly Scenario[] =
(profile === 'nexus-stronghold' || scenario.roster.length >= 3))
.map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 800 + band * 100 + index })));
const discoveryNexusProfile = (profile: string) =>
(e3NexusSampleProfile(profile) && profile.includes('-discovery')) || profile === 'discovery-nexus' ||
((e3NexusSampleProfile(profile) || singleNativeNexusSampleProfile(profile)) && profile.includes('-discovery')) || profile === 'discovery-nexus' ||
profile === 'discovery-nexus-tech' || profile === 'discovery-nexus-stronghold' ||
profile === 'discovery-nexus-stronghold-tech' || discoveryNexusSkillsProfile(profile) ||
pairedDiscoveryNexusSampleProfile(profile) || nativeDiscoveryNexusSkillsProfile(profile);
const nexusModuleProfile = (profile: string) =>
e3NexusSampleProfile(profile) || homeworldNexusSampleProfile(profile) ||
singleNativeNexusSampleProfile(profile) || e3NexusSampleProfile(profile) || 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) =>
(e3NexusSampleProfile(profile) && profile.endsWith('-tech')) ||
((e3NexusSampleProfile(profile) || singleNativeNexusSampleProfile(profile)) && profile.endsWith('-tech')) ||
((discoveryNexusSkillsProfile(profile) || pairedDiscoveryNexusSampleProfile(profile) || nativeDiscoveryNexusSkillsProfile(profile)) && profile.endsWith('tech')) ||
profile === 'discovery-nexus-tech' || profile === 'discovery-nexus-stronghold-tech' || profile === 'nexus-tech' || profile === 'nexus-stronghold-tech' ||
profile === 'paired-nexus-tech' || profile === 'paired-nexus-stronghold-tech' ||
@@ -297,7 +310,7 @@ const nexusTechProfile = (profile: string) =>
profile === 'paired-nexus-skills-tech' || profile === 'paired-nexus-skills-stronghold-tech' ||
profile === 'e3-nexus-skills-tech' || profile === 'e3-nexus-skills-stronghold-tech';
const nexusStrongholdProfile = (profile: string) =>
(e3NexusSampleProfile(profile) && profile.includes('stronghold')) ||
((e3NexusSampleProfile(profile) || singleNativeNexusSampleProfile(profile)) && profile.includes('stronghold')) ||
((discoveryNexusSkillsProfile(profile) || pairedDiscoveryNexusSampleProfile(profile) || nativeDiscoveryNexusSkillsProfile(profile)) && profile.includes('stronghold')) ||
profile === 'discovery-nexus-stronghold' || profile === 'discovery-nexus-stronghold-tech' || profile === 'nexus-stronghold' || profile === 'nexus-stronghold-tech' ||
profile === 'paired-nexus-stronghold' || profile === 'paired-nexus-stronghold-tech' ||
@@ -580,6 +593,19 @@ const E3_NEXUS_MODULE_SCENARIOS: readonly Scenario[] =
.filter(scenario => (!nexusStrongholdProfile(profile) || scenario.rules === 'advanced') &&
(!nexusTechProfile(profile) || scenario.roster.length >= 3))
.map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 12600 + band * 100 + index })));
const SINGLE_NATIVE_NEXUS_BASE_SCENARIOS: readonly Scenario[] =
(['ixians', 'tleilaxu', 'choam', 'richese'] as const).flatMap(native =>
(['basic', 'advanced'] as const).flatMap(rules => [2, 3, 4, 5, 6].map((players): Scenario => ({
profile: 'single-nexus', ordinal: 0, rules,
expansions: [native === 'ixians' || native === 'tleilaxu' ? 'ix' : 'choam'],
roster: ([native, 'emperor', 'guild', 'harkonnen', 'fremen', 'beneGesserit'] as FactionId[]).slice(0, players),
}))));
const SINGLE_NATIVE_NEXUS_MODULE_SCENARIOS: readonly Scenario[] =
SINGLE_NATIVE_NEXUS_MODULE_PROFILES.flatMap((profile, band) =>
SINGLE_NATIVE_NEXUS_BASE_SCENARIOS
.filter(scenario => (!nexusStrongholdProfile(profile) || scenario.rules === 'advanced') &&
(!nexusTechProfile(profile) || scenario.roster.length >= 3))
.map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 14200 + band * 100 + index })));
const STRONGHOLD_FACTIONS_ROSTER: readonly FactionId[] = [
'ixians', 'choam', 'emperor', 'fremen', 'harkonnen', 'beneGesserit',
];
@@ -658,7 +684,8 @@ const SKILLS_STRONGHOLD_SCENARIOS: readonly Scenario[] =
const strongholdSkillProfile = (profile: string) => profile === 'skills-stronghold' || profile === 'skills-stronghold-tech';
const discoverySkillsProfile = (profile: string) => profile === 'discovery-skills' || profile === 'discovery-skills-tech' ||
profile === 'discovery-skills-stronghold' || profile === 'discovery-skills-stronghold-tech';
const skillProfile = (profile: string) => homeworldSkillsSampleProfile(profile) || nativeDiscoveryNexusSkillsProfile(profile) || discoveryNexusSkillsProfile(profile) || nativeDiscoverySkillsProfile(profile) || discoverySkillsProfile(profile) || e3NexusSkillsProfile(profile) || pairedNexusSkillsModuleProfile(profile) || nexusSkillsModuleProfile(profile) || profile === 'nexus-skills' || profile === 'paired-nexus-skills' || profile === 'moritani-skills' || profile === 'tleilaxu-skills' || profile === 'ix-skills' || profile === 'choam-skills' || profile === 'richese-skills' || profile === 'ecaz-skills' || profile === 'skills-tech' || strongholdSkillProfile(profile);
const skillProfile = (profile: string) => (singleNativeNexusSampleProfile(profile) && profile.includes('-skills')) ||
homeworldSkillsSampleProfile(profile) || nativeDiscoveryNexusSkillsProfile(profile) || discoveryNexusSkillsProfile(profile) || nativeDiscoverySkillsProfile(profile) || discoverySkillsProfile(profile) || e3NexusSkillsProfile(profile) || pairedNexusSkillsModuleProfile(profile) || nexusSkillsModuleProfile(profile) || profile === 'nexus-skills' || profile === 'paired-nexus-skills' || profile === 'moritani-skills' || profile === 'tleilaxu-skills' || profile === 'ix-skills' || profile === 'choam-skills' || profile === 'richese-skills' || profile === 'ecaz-skills' || profile === 'skills-tech' || strongholdSkillProfile(profile);
const discoveryProfile = (profile: string) => profile === 'discovery' || profile === 'discovery-tech' ||
profile === 'discovery-stronghold' || profile === 'discovery-stronghold-tech' ||
(homeworldDiscoverySampleProfile(profile) && !homeworldSkillsSampleProfile(profile));
@@ -703,9 +730,10 @@ function usage() {
return (
'Usage: node --import tsx tools/faction-games.ts --out NEW_PRIVATE_DIR ' +
'[--seed UINT32] [--profile all|base|discovery|discovery-tech|discovery-skills|discovery-skills-tech|discovery-nexus|discovery-nexus-tech|discovery-nexus-stronghold|discovery-nexus-stronghold-tech|choam|ecaz|ecaz-treachery|ecaz-occupy|moritani-assassinate|stronghold-factions|factions-tech|stronghold-factions-tech|combined|combined-nexus|combined-homeworld-nexus|homeworld|homeworld-occupation|nexus|nexus-skills|paired-nexus-skills|nexus-skills-tech|nexus-skills-stronghold|nexus-skills-stronghold-tech|paired-nexus-skills-tech|paired-nexus-skills-stronghold|paired-nexus-skills-stronghold-tech|e3-nexus-skills|e3-nexus-skills-tech|e3-nexus-skills-stronghold|e3-nexus-skills-stronghold-tech|nexus-tech|nexus-stronghold|nexus-stronghold-tech|paired-nexus-tech|paired-nexus-stronghold|paired-nexus-stronghold-tech|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|' +
E3_NEXUS_MODULE_PROFILES.join('|') + '] ' +
SINGLE_NATIVE_NEXUS_MODULE_PROFILES.join('|') + '|' + E3_NEXUS_MODULE_PROFILES.join('|') + '] ' +
'[--rules both|basic|advanced] [--players all|2|3|4|5|6] [--max-actions POSITIVE] ' +
'[--resume FAILED_GAME.json]\n' +
'Single-native E1/E2: single-nexus and single-discovery-nexus, single-homeworld-nexus and single-homeworld-nexus-discovery, each with optional -skills and -tech/-stronghold/-stronghold-tech. Exactly one original Ixians OR Tleilaxu OR CHOAM OR Richese with classics, one family deck/all12 and all14 only when Skills selected. Basic/Advanced2–6, Tech3+, Strongholds Advanced; original setup/effects/ruling guards remain. Stable new14200–17300 bands; older names/seeds unchanged.\n' +
'Standalone E3 without Skills: e3-nexus, e3-discovery-nexus, e3-homeworld-nexus and e3-homeworld-nexus-discovery, each optionally -tech, -stronghold or -stronghold-tech. Original Ecaz OR Moritani/classic ecaz33/all12 setup, Basic/Advanced2–6, Tech3+, Strongholds Advanced, no training/public starts/save conversion. Native Duke/Occupy and Moritani traitor/Terror/assassination retain their original source and ruling boundaries. Stable new12600–14100 bands leave older names/seeds unchanged.\n' +
'Native trained Discovery/Nexus profiles: paired-discovery-nexus-skills[-tech|-stronghold|-stronghold-tech] retains original paired E1/E2 setup/all14; e3-discovery-nexus-skills[-tech|-stronghold|-stronghold-tech] retains standalone Ecaz OR Moritani original setup/all14. Discovery7/8/all12, Basic/Advanced2–6, Tech3+, Strongholds Advanced; original roster/custody/pending-rule limits and old defaults/seeds remain.\n' +
'Discovery Nexus combinations: discovery-nexus-skills[-tech|-stronghold|-stronghold-tech] uses original classic all14 training; paired-discovery-nexus[-tech|-stronghold|-stronghold-tech] uses both native E1 or E2 factions without Skills. Basic/Advanced2–6, Tech3+, Strongholds Advanced, no Homeworlds/variants/previews; old profiles/defaults/seeds unchanged.\n' +
@@ -734,7 +762,7 @@ function usage() {
+ '\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.'
+ '\nPaired-homeworld-nexus-skills, paired-homeworld-nexus-discovery and paired-homeworld-nexus-discovery-skills retain original both-native E1 or E2 roster/deck contracts, Homeworlds/all12 and optional all14/DS7+8, Basic/Advanced2..6. Families add -tech at3+, -stronghold inAdvanced2+, -stronghold-tech inAdvanced3+. Paired-homeworld-nexus-tech/-stronghold/-stronghold-tech omit Skills/Discovery. E3-homeworld-nexus-skills and e3-homeworld-nexus-discovery-skills retain standalone Ecaz OR Moritani/classics, Ecaz33/all14/all12, with the same optional prerequisites and original Advanced Harkonnen/Duke/assassination guards. Original typed custody, real closing deals, physical rescue/substitution/marker invoices, phase-end industry and winner/held timing remain. Separate no-Skills E3 families above retain their own roster/effect predicates. Other native singles/mixed decks, E3pairs/mixed families, pending effects, public starts and save conversion stay separate.'
+ '\nPaired-homeworld-nexus-skills, paired-homeworld-nexus-discovery and paired-homeworld-nexus-discovery-skills retain original both-native E1 or E2 roster/deck contracts, Homeworlds/all12 and optional all14/DS7+8, Basic/Advanced2..6. Families add -tech at3+, -stronghold inAdvanced2+, -stronghold-tech inAdvanced3+. Paired-homeworld-nexus-tech/-stronghold/-stronghold-tech omit Skills/Discovery. E3-homeworld-nexus-skills and e3-homeworld-nexus-discovery-skills retain standalone Ecaz OR Moritani/classics, Ecaz33/all14/all12, with the same optional prerequisites and original Advanced Harkonnen/Duke/assassination guards. Original typed custody, real closing deals, physical rescue/substitution/marker invoices, phase-end industry and winner/held timing remain. Separate single-native E1/E2 and no-Skills E3 families above retain their own roster/effect predicates. Mixed decks, E3pairs/mixed families, pending effects, public starts and save conversion stay separate.'
);
}
@@ -765,7 +793,7 @@ function positive(value: string | undefined) {
}
function scenarioName(scenario: Scenario) {
if (e3NexusSampleProfile(scenario.profile) || homeworldModuleSampleProfile(scenario.profile))
if (singleNativeNexusSampleProfile(scenario.profile) || e3NexusSampleProfile(scenario.profile) || homeworldModuleSampleProfile(scenario.profile))
return `${scenario.profile}-${scenario.expansions.join('-') || 'classic'}-${scenario.roster.join('-')}-${scenario.roster.length}-${scenario.rules}`;
if (nativeDiscoverySkillsProfile(scenario.profile) || discoveryNexusSkillsProfile(scenario.profile) ||
pairedDiscoveryNexusSampleProfile(scenario.profile) || nativeDiscoveryNexusSkillsProfile(scenario.profile))
@@ -935,11 +963,11 @@ function resumedGame(path: string) {
if (
(game.leaderSkills && !nativeExpansionLeaderSkillsProfile(game) && !composedSkills) ||
((game.discoveryEnabled || game.discoveries || game.discoveryStash || game.greatMaker) && !discovery) ||
(game.techTokens && !discovery && !composedSkills && !nativeFactionTechProfile(game) && !classicNexusModulesProfile(game) && !pairedNexusModulesProfile(game) && !standaloneE3NexusProfile(game)) ||
(game.techTokens && !discovery && !composedSkills && !nativeFactionTechProfile(game) && !classicNexusModulesProfile(game) && !pairedNexusModulesProfile(game) && !singleE1E2NexusProfile(game) && !standaloneE3NexusProfile(game)) ||
game.mentatQuestionPreview
)
throw new Error('--resume sample scenarios exclude unsupported optional modules.');
const scenario = [...SCENARIOS, ...E3_NEXUS_MODULE_SCENARIOS, ...HOMEWORLD_NATIVE_NEXUS_MODULE_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) =>
const scenario = [...SCENARIOS, ...SINGLE_NATIVE_NEXUS_MODULE_SCENARIOS, ...E3_NEXUS_MODULE_SCENARIOS, ...HOMEWORLD_NATIVE_NEXUS_MODULE_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 &&
@@ -1162,7 +1190,8 @@ async function main() {
if ((discoveryTechProfile(profile) || (discoveryNexusProfile(profile) && nexusTechProfile(profile))) && players === 2)
throw new Error('Discovery with Tech Tokens requires three through six players.');
const resume = values.resume ? resumedGame(values.resume) : null;
const requestedSamples = e3NexusSampleProfile(profile) ? E3_NEXUS_MODULE_SCENARIOS : homeworldModuleSampleProfile(profile)
const requestedSamples = singleNativeNexusSampleProfile(profile) ? SINGLE_NATIVE_NEXUS_MODULE_SCENARIOS :
e3NexusSampleProfile(profile) ? E3_NEXUS_MODULE_SCENARIOS : homeworldModuleSampleProfile(profile)
? (nativeHomeworldNexusSampleProfile(profile) ? HOMEWORLD_NATIVE_NEXUS_MODULE_SCENARIOS
: homeworldNexusSampleProfile(profile) ? HOMEWORLD_CLASSIC_NEXUS_MODULE_SCENARIOS
: profile.startsWith('native-') ? HOMEWORLD_NATIVE_MODULE_SCENARIOS : HOMEWORLD_CLASSIC_MODULE_SCENARIOS)
+6 -5
View File
@@ -35,17 +35,18 @@ async function main() {
'\nNexus preserves selected unused original Tech Tokens in classic/base33/all12 three-through-six-seat Basic/Advanced lobbies, and/or unused Stronghold Cards in Advanced two-through-six-seat lobbies; both requires3+. Existing Homeworlds may be retained. Genuine first-Storm/end-Mentat custody and closing Nexus deals remain. Borrowed tariff/force identity, original free-return ledgers, phase-end industry, held support and retention/winner rewards reuse existing handlers. Skills, expansion families outside their documented entries, unrelated previews, unresolved effects and public starts remain separate.',
'\nNexus additionally routes a supported paired native E1 (Ixians+Tleilaxu) OR E2 (CHOAM+Richese) lobby with classics and one required deck through original paired setup when Tech and/or Advanced Strongholds is selected. Tech requires3–6 Basic/Advanced, Strongholds2–6 Advanced, both3–6 Advanced. All12 Nexus, original47/35 decks/cache, native setup and actual module custody remain. Mixed families, E3, Skills, other overlays, public starts and pending rulings stay separate.',
'\nNexus additionally admits standalone Ecaz OR Moritani with classics and the original ecaz33/all12 deck without Skills, Basic/Advanced2–6, optionally original Homeworlds, Discovery through --discoveries, Tech3+ and Advanced Strongholds2+. Original Traitors/native setup and qualifying end-Spice closing draw remain; no starting Nexus deal or training is added. Ecaz Arrakis Occupy and quiet living-Duke Cunning retain their own boundaries; an otherwise legal Advanced Ecaz/Harkonnen roster does not lift the separate Cunning exclusion. Advanced Moritani excludes Harkonnen and uses original post-loss assassination/normal-call forfeiture, private replacement and supply-only Terror Cunning. E3 pairs/mixed families, native free-return/relocation rulings, unrelated previews, public starts and save conversion stay separate.',
'\nLeader-skills --nexus-cards explicitly adds all12 Nexus to fresh classic/base33 OR paired Ixians+Tleilaxu/Ix47 OR paired CHOAM+Richese/CHOAM35+cache setups with classics and all14 skills, two-through-six seats Basic/Advanced. Existing Homeworlds may be retained. Original starting hands/offers, skill/Traitor/native order and genuine closing deals remain. Mixed families/E3 pairs, Banker/Mentat previews, borrowed Smuggler/pair-companion arithmetic, public starts and unresolved interactions stay separate.',
'\nThese classic or paired one-family skill-Nexus entries preserve selected unused original Tech Tokens at3–6 Basic/Advanced and/or Stronghold Cards at2–6 Advanced; both require3–6 Advanced. Real first-Storm/end-Mentat custody, phase-end industry, held subsidy, typed skill rescue/equal substitution, signed one-invoice marker pairs and mandatory original-winner Tech before Face Dance use original handlers. No mixed-family/native free-return accounting or pending rulings are opened.',
'\nNexus additionally admits exactly one native Ixians OR Tleilaxu with the Ix47 deck, or CHOAM OR Richese with CHOAM35 and the original Richese cache when seated, plus classic opponents, Basic/Advanced2–6. Original Homeworlds may be retained; --discoveries preserves Discovery7/8, Tech requires3+ and Strongholds require Advanced2+. Without Skills it reuses original faction/Traitor/native setup; leader-skills --nexus-cards additionally retains all14 offered training. Original Ix offer/HMS, Tleilaxu Face Dancers, CHOAM Auditor and signed Richese markers remain physical. The paired entry still requires both native factions. Mixed-family Nexus, E3pairs, public starts, played-save conversion and pending effect rulings remain separate.',
'\nLeader-skills --nexus-cards explicitly adds all12 Nexus to fresh classic/base33 OR single or paired Ixians/Tleilaxu with Ix47 OR single or paired CHOAM/Richese with CHOAM35 and native Richese cache, plus classics and all14 skills, two-through-six seats Basic/Advanced. Existing Homeworlds may be retained. Original starting hands/offers, skill/Traitor/native order and genuine closing deals remain. Mixed families/E3 pairs, Banker/Mentat previews, borrowed Smuggler/pair-companion arithmetic, public starts and unresolved interactions stay separate.',
'\nThese classic, single-native or paired one-family skill-Nexus entries preserve selected unused original Tech Tokens at3–6 Basic/Advanced and/or Stronghold Cards at2–6 Advanced; both require3–6 Advanced. Real first-Storm/end-Mentat custody, phase-end industry, held subsidy, typed skill rescue/equal substitution, signed one-invoice marker pairs and mandatory original-winner Tech before Face Dance use original handlers. No mixed-family/native free-return accounting or pending rulings are opened.',
'\nThe same fresh skill/Nexus entry admits standalone Ecaz OR Moritani with classics, exact ecaz33/all14/all12, Basic/Advanced2–6. Optional unused Tech requires3+; Strongholds requires Advanced; both3+ Advanced. Native Ecaz five-disc/Duke assignment and quiet living-Duke Cunning, plus original Advanced Moritani skill-first assassination/normal-traitor forfeiture, remain. Advanced Harkonnen, shared-Duke assignment, enhanced Terror relocation/HMS/Grumman and other pending effects stay guarded.',
'\nIn the standalone Ecaz skill/Nexus profile, mandatory Advanced Occupy composes the chosen faction’s skill with separately labelled armies. Ecaz-led Suk rescues its own fixed casualties after the ally’s variable losses; Diplomat uses the chosen faction’s undialed own pool. Shared training, Basic odd rounding and other pending effects remain separate.',
'\nHomeworld-occupation opts a fresh ready 2–6-seat Basic/Advanced lobby with original selected faction decks into occupied bank Collection/immediate ally sharing, Kaitain/Junction/Richese percentage receipts, completed total Southern Hemisphere Collection, Caladan shared inspection, displaced Ix pool control, Richese card-only selection with native seller terms, Giedi receiver choice and occupied native defenses. Advanced follows the authorized supplied rulebook p22: sole foreign qualification, retained until its last force leaves; later contests/native repopulation do not expire it, and a new sole arrival after departure has a new source epoch. Basic retains unresolved lifecycle guards. Native income/peek cancellation remains distinct from printed occupied effects. Other borrowed/exceptional powers, overlays, public starts and old-game conversion remain separate.',
'\nWhen native CHOAM is seated, this fresh entry additionally marks original occupied Tupile hand slots and owner-selected normal-limit cleanup. The occupier/reciprocal ally gain one validated slot through existing capacity consumers; Advanced departure and ally removal queue the original lost-holder cleanup. Private selected-ID discards preserve current phase/opening and remaining movement, with no new card or fee. Known occupation suppresses new native low-intelligence queries; proved Advanced expiry restores eligibility without resetting historical answers/usage. Basic unknown cases retain source guards. Unmarked played profiles are not upgraded.',
'\nWhen native Ecaz is seated, this original fresh entry separately marks its printed high Homeworld alliance victory: current native seven-plus, one actual joint stronghold, two distinct other native factions with proved current occupied holdings. It adds no ordinary stronghold/Tech points, pays no reward and changes no Duke authority. Public progress remains prospective until original Mentat; qualifying members join original winners before BG prediction/final fallback. Unmarked played/public profiles remain unchanged.',
'\nDiscovery opts fresh classic, selected E1/E2 native families or standalone Ecaz OR Moritani with classics into original Discovery setup in Basic/Advanced2–6, optionally retaining 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; adding --nexus-cards requires classic factions, both native Ixians+Tleilaxu OR CHOAM+Richese with one family deck, or standalone Ecaz OR Moritani with classics. Tech requires3–6 and Strongholds Advanced2–6. Original private card choices/training/hands, Maker/votes/rides, physical residual Cyborg exchange, native aftermath and cleanup/Tech/Face Dance retain their own timing. Other native singles/mixed decks, 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, both native Ixians+Tleilaxu/Ix47 OR CHOAM+Richese/CHOAM35+cache, or standalone Ecaz OR Moritani/ecaz33 with classics, all12 Nexus and original Discovery7/8 at2–6 seats Basic/Advanced without Skills, optionally retaining original Homeworlds. 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. E3pairs/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+. Classic, paired E1/E2 or standalone Ecaz OR Moritani/classic entries additionally compose Nexus through original leader-skills --nexus-cards with optional --discoveries, or original nexus with optional --discoveries/Tech/Strongholds without Skills. Native roster/deck/assassination/Duke boundaries remain. Original all14 when Skills selected/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. Other native singles/mixed-deck 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; adding --nexus-cards requires classic factions, single or paired native Ixians/Tleilaxu OR CHOAM/Richese with one family deck, or standalone Ecaz OR Moritani with classics. Tech requires3–6 and Strongholds Advanced2–6. Original private card choices/training/hands, Maker/votes/rides, physical residual Cyborg exchange, native aftermath and cleanup/Tech/Face Dance retain their own timing. Mixed decks, 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, single or paired native Ixians/Tleilaxu with Ix47 OR CHOAM/Richese with CHOAM35 and native Richese cache, or standalone Ecaz OR Moritani/ecaz33 with classics, all12 Nexus and original Discovery7/8 at2–6 seats Basic/Advanced without Skills, optionally retaining original Homeworlds. 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. E3pairs/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+. Classic, single-native or paired E1/E2, or standalone Ecaz OR Moritani/classic entries additionally compose Nexus through original leader-skills --nexus-cards with optional --discoveries, or original nexus with optional --discoveries/Tech/Strongholds without Skills. Native roster/deck/assassination/Duke boundaries remain. Original all14 when Skills selected/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. Mixed-deck Nexus, unsupported E3pair/mixed rosters, unrelated previews, public starts and save conversion remain separate.',
);
return;
}