Compose classic Nexus with original Discovery modules

This commit is contained in:
Quintine committed 2026-10-05 21:23:02 +11:00
1 parent 4672b97417
commit a2a92bb408
18 files changed
+1458 -28

No files matched your search

+18
View File
@@ -17,6 +17,24 @@ open mode gates.
## Current checkpoint and work ## Current checkpoint and work
**Classic Nexus / Discovery — bounded Development Verified:** fresh explicit
`nexus --discoveries` composes classic/base33/all12/Discovery7/8,
Basic/Advanced2–6, original Tech3+ and/or Advanced Strongholds2+.
Original setup, end-Mentat ownership, next-turn physical free entry, Great
Maker's actual worm/vote/typed ride and end-Spice closing draw remain.
Source-clear Emperor/Fremen revival ledgers and Guild paid nested shipment/
battle/Tech use existing consumers. Skills/native/HW/variants/previews and
unresolved mixed-module Fremen Betrayal stay separate; no public activation.
Affected64, types/lint/build and actual programmes pass. Two genuine six-seat
Basic Tech / Advanced four-module games finish1,150/1,150/31 JSON on unchanged
761f5ca5 tree. Actual3D7SAG45 original CLIv8, human free-entryv10,
Great Maker votesv12–14/typed ridev15 and original Advanced blow/alliance
settlementv16–26 culminate in Guild's physical closing drawv27/Charity.
Richese delivered, original deck12→11,390px refresh. Controlled unplayed
Spice order/token lottery is explicit; no natural-every-effect or production
claim. [Contract and evidence](DISCOVERY_PROTOTYPE.md#classic-nexus-composition--5-october-2026).
**Advanced Orgiz / classic Discovery Skills — bounded Development Verified:** **Advanced Orgiz / classic Discovery Skills — bounded Development Verified:**
the supplied Advanced rulebook p24 uses a territory-Collection trigger. the supplied Advanced rulebook p24 uses a territory-Collection trigger.
Advanced transfers one collected spice per territory with a unique rival payer; Advanced transfers one collected spice per territory with a unique rival payer;
+49
View File
@@ -35,6 +35,55 @@ node --import tsx tools/start-prototype.ts --profile discovery --db PATH --room
It preserves all seats and other rooms, requires the current version and refuses It preserves all seats and other rooms, requires the current version and refuses
to redeal a started game. The default `ix` profile remains available. to redeal a started game. The default `ix` profile remains available.
## Classic Nexus composition — 5 October 2026
Fresh local `nexus --discoveries` composes classic/base33/all12 Nexus with the
original Discovery seven Spice Cards/eight tokens, Basic/Advanced two through
six seats. Original unused Tech requires three seats; Strongholds requires
Advanced. No Skills, native families, Homeworlds, independent card variants,
unrelated previews, public start or played-game conversion is admitted.
```sh
node --import tsx tools/start-prototype.ts --profile nexus --discoveries --db PATH --room CODE --version NUMBER --out /private/new-directory
node --import tsx tools/faction-games.ts --profile discovery-nexus-stronghold-tech --rules advanced --players 6 --out /private/new-directory
```
The game runner also offers `discovery-nexus`, `discovery-nexus-tech` and
`discovery-nexus-stronghold`, without changing earlier defaults or seeds.
Original setup and inventories remain authoritative. End-Mentat settles held
Strongholds; next-turn free entry precedes Storm. Great Maker finishes ordinary
worm losses, then its storm-order vote and physical reserve ride, including
typed Fremen and revealed nested destinations. It does not itself deal cards.
The original closing Nexus choice occurs only at the end of the entire Spice
Blow phase, after both Advanced piles, when a Nexus occurred and an alliance
exists. Joining an alliance forfeits a held card; unallied players retain the
original keep/draw/replacement and own-faction redraw choices.
Source-clear Emperor extra-three and absent-Fremen ordinary-three revival
retain distinct quota/free ledgers and actual Axlotl activity. Absent-Guild
borrowed tariffs preserve native identity, physical nested shipment and one
card cost; original nested battle retains winner cards and mandatory Tech
reward. Existing unresolved mixed-module Fremen Betrayal and other effects
remain gated; no universal Great Maker interruption priority is invented.
Affected64, types/lint/build and original runtime programmes pass. Two genuine
six-seat Basic Tech / Advanced Tech+Stronghold games finish1,150 accepted
actions/1,150 attempts/31 JSON continuations on unchanged tree
`761f5ca592af1bd3be766dd0e5654ed18528809855f6139436552428389f5b00`.
These totals do not claim natural use of every effect.
Actual isolated QA room **3D7SAG45** preserves authenticated seats and original
CLI setupv8. Explicit unplayed Spice order/token lottery and43 original actions
reach next-turn entryv9. Human Guild enters one Cistern counterv10 without
money/reserve/movement cost. Six original actions open Great Makerv11 after
actual Emperor losses. Human votesv12–14 create a Nexus; Fremen's actual typed
two-counter ridev15 includes one Fedaykin, costs no spice and leaves Guild's
protected nested counter intact. Human Advanced blow/alliance controlsv16–26
form Emperor/Fremen's alliance and open Guild's real closing draw.
Human drawv27 delivers Richese, decrements the original twelve-card deck to
eleven and releases Charity. Refresh fits390px. This is controlled source
exercise, not unstaged card history or production verification.
## Native setup and original consumers — 5 October 2026 ## Native setup and original consumers — 5 October 2026
Original Ixian setup keeps the six-counter HMS garrison and seven physical Original Ixian setup keeps the six-counter HMS garrison and seven physical
+21
View File
@@ -1,5 +1,26 @@
# Dune implementation status # Dune implementation status
## 5 October 2026 — Classic Nexus and original Discovery composition
Fresh explicit `nexus --discoveries` admits classic/base33/all12/Discovery7/8
Basic/Advanced2–6 and optional original Tech3+/Advanced Strongholds2+.
Original setup/first-Storm Tech/end-Mentat custody/next-turn entry, Great
Maker worm/vote/typed ride and end-Spice closing Nexus draws remain.
Source-clear revival ledgers, native-preserving Guild nested tariffs and
actual winner/card/Tech continuation compose existing consumers. Skills,
native/HW/variants/previews and unresolved effects remain separate.
Affected64, types/lint/build and actual programmes pass. Two genuine six-seat
games finish1,150/1,150/31 JSON on unchanged
`761f5ca592af1bd3be766dd0e5654ed18528809855f6139436552428389f5b00`.
Original CLI room3D7SAG45v8 and explicit undealt order/lottery lead to human
one-counter free entryv10, real Great Maker votesv12–14, typed two-counter/
one-Fedaykin reserve ridev15, Advanced blow and actual alliance settlement,
then human Guild closing drawv27/Charity: physical Richese/deck12→11,
390px refresh. Source staging is explicit, no natural-every-effect claim.
[Canonical contract](DISCOVERY_PROTOTYPE.md#classic-nexus-composition--5-october-2026).
No production deployment or complete-mode acceptance is claimed.
## 5 October 2026 — Advanced Orgiz and classic Discovery Skills ## 5 October 2026 — Advanced Orgiz and classic Discovery Skills
Authorized supplied Advanced p24 territory-Collection wording now groups actual Authorized supplied Advanced p24 territory-Collection wording now groups actual
+24
View File
@@ -2,6 +2,30 @@
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. 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.
## Classic Discovery / original Tech and Strongholds — 5 October 2026
Fresh local `nexus --discoveries` now admits classic/base33/all12 Nexus and
Discovery7/8, Basic/Advanced2–6, optional unused original Tech3+ and Advanced
Strongholds2+. Skills/native families/Homeworlds/variants/previews, public
starts and save conversion remain separate. Original inventories and setup,
first-Storm Tech, end-Mentat held custody and next-turn physical entry remain.
Great Maker's vote/typed reserve ride feeds the original phase-one Nexus.
It does not grant an immediate draw or a Mentat draw: finish both Advanced
Spice piles, actual alliance settlement and the original closing unallied
choice. No settled alliance means no cards. Source-clear absent-Emperor
extra-three versus absent-Fremen ordinary-three returns retain distinct
ledgers/Axlotl activity; borrowed Guild tariffs preserve native pieces, one
physical card cost and actual nested battle/reward continuation. Unresolved
mixed-module Fremen Betrayal remains gated.
The [Discovery contract](DISCOVERY_PROTOTYPE.md#classic-nexus-composition--5-october-2026)
records affected64, types/lint/build, original lifecycle/effect programmes,
two genuine six-seat games (1,150 accepted/no rejection/31 JSON on one unchanged
tree), and actual room3D7SAG45 human free entry, typed ride, alliance and closing
drawv27/Charity at390px. These are bounded development proofs, not complete
module, natural-every-effect or deployed acceptance.
## Standalone E3 Nexus, Skills and original components — 5 October 2026 ## Standalone E3 Nexus, Skills and original components — 5 October 2026
The original fresh `leader-skills --nexus-cards` entry admits **Ecaz OR The original fresh `leader-skills --nexus-cards` entry admits **Ecaz OR
+1
View File
@@ -236,6 +236,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP
| Paired Nexus / Skills / Tech / Strongholds | Both Ixians+Tleilaxu OR CHOAM+Richese plus classics and one original family deck/all14/all12 preserve selected unused Tech3..6 Basic/Advanced and/or Strongholds2..6 Advanced through the original fresh skill/Nexus entry. Typed rescue precedes equal actual Cyborg Tank substitution, original card cleanup/mandatory winner Tech precedes Face Dance; held subsidy changes debit, not committed support, and reward/custody stay original winner. Signed marker pair/one invoice/native industry retains original timing. Bounded runtime/program/phone/genuine-game evidence does not certify full modules; mixed families/E3/HW/previews/companions/native Nexus free-return rulings/public start/save conversion stay guarded. | [Paired combined contract and evidence](NEXUS_CARD_RULES.md#paired-nexus-skills-and-original-techstrongholds--5-october-2026) | | Paired Nexus / Skills / Tech / Strongholds | Both Ixians+Tleilaxu OR CHOAM+Richese plus classics and one original family deck/all14/all12 preserve selected unused Tech3..6 Basic/Advanced and/or Strongholds2..6 Advanced through the original fresh skill/Nexus entry. Typed rescue precedes equal actual Cyborg Tank substitution, original card cleanup/mandatory winner Tech precedes Face Dance; held subsidy changes debit, not committed support, and reward/custody stay original winner. Signed marker pair/one invoice/native industry retains original timing. Bounded runtime/program/phone/genuine-game evidence does not certify full modules; mixed families/E3/HW/previews/companions/native Nexus free-return rulings/public start/save conversion stay guarded. | [Paired combined contract and evidence](NEXUS_CARD_RULES.md#paired-nexus-skills-and-original-techstrongholds--5-october-2026) |
| Classic Nexus / Leader Skills | Fresh classic/base33/all14 Skills/all12 Nexus, Basic/Advanced2..6, explicit `leader-skills --nexus-cards`, no other overlays/previews. Original starting hands/offers/skill-before-Traitor setup and actual closing Nexus draw remain. Exact extra/ordinary free-return ledgers, real bank purchase and committed Traitor declarations preserve trained-disc custody. Temporary ordinary-as-Sardaukar roles compose actual skill posture/bonuses/support; matching Cunning casualty receipt stays pending through original Suk rescue and completes only after physical settlement. Source-guarded borrowed Smuggler arithmetic, Atreides Suk/KH and unresolved effects remain; no public start, retrofit or new ruling. | [Scope, sources and bounded evidence](NEXUS_CARD_RULES.md#classic-nexus-and-leader-skills-composition--4-october-2026), [Skill runtime](LEADER_SKILLS_RUNTIME.md#classic-nexus-composition--4-october-2026) | | Classic Nexus / Leader Skills | Fresh classic/base33/all14 Skills/all12 Nexus, Basic/Advanced2..6, explicit `leader-skills --nexus-cards`, no other overlays/previews. Original starting hands/offers/skill-before-Traitor setup and actual closing Nexus draw remain. Exact extra/ordinary free-return ledgers, real bank purchase and committed Traitor declarations preserve trained-disc custody. Temporary ordinary-as-Sardaukar roles compose actual skill posture/bonuses/support; matching Cunning casualty receipt stays pending through original Suk rescue and completes only after physical settlement. Source-guarded borrowed Smuggler arithmetic, Atreides Suk/KH and unresolved effects remain; no public start, retrofit or new ruling. | [Scope, sources and bounded evidence](NEXUS_CARD_RULES.md#classic-nexus-and-leader-skills-composition--4-october-2026), [Skill runtime](LEADER_SKILLS_RUNTIME.md#classic-nexus-composition--4-october-2026) |
| Paired E1/E2 Nexus / Leader Skills | Fresh both Ixians+Tleilaxu OR CHOAM+Richese plus classics, one original family deck/all14 Skills/all12 Nexus, Basic/Advanced2..6, explicit original skill entry. Actual native setup/offers/closing draws remain. Full unsupported Suboids compose typed Suk rescue before equal actual Tank substitution; Face Dance follows original winner outcomes, kills/returns the winning trainer skill once without another bounty, and partial Cunning retains ordinary Mentat rights. Richese pair uses its original signed two-token frame/one invoice/native response/Guild continuation, not single-marker skill proof; CHOAM printed fuel/Planetologist physical route remain separate. No other overlays, companion/mixed-plan/new ruling/public starts or retrofit. | [Scope, sources and bounded evidence](NEXUS_CARD_RULES.md#paired-e1e2-nexus-and-leader-skills-composition--4-october-2026), [Skill runtime](LEADER_SKILLS_RUNTIME.md#paired-e1e2-nexus-composition--4-october-2026) | | Paired E1/E2 Nexus / Leader Skills | Fresh both Ixians+Tleilaxu OR CHOAM+Richese plus classics, one original family deck/all14 Skills/all12 Nexus, Basic/Advanced2..6, explicit original skill entry. Actual native setup/offers/closing draws remain. Full unsupported Suboids compose typed Suk rescue before equal actual Tank substitution; Face Dance follows original winner outcomes, kills/returns the winning trainer skill once without another bounty, and partial Cunning retains ordinary Mentat rights. Richese pair uses its original signed two-token frame/one invoice/native response/Guild continuation, not single-marker skill proof; CHOAM printed fuel/Planetologist physical route remain separate. No other overlays, companion/mixed-plan/new ruling/public starts or retrofit. | [Scope, sources and bounded evidence](NEXUS_CARD_RULES.md#paired-e1e2-nexus-and-leader-skills-composition--4-october-2026), [Skill runtime](LEADER_SKILLS_RUNTIME.md#paired-e1e2-nexus-composition--4-october-2026) |
| Classic Nexus / Discovery / original components | Fresh classic/base33/all12/Discovery7/8 Basic/Advanced2–6, original Tech3+/Advanced Strongholds2+, explicit `nexus --discoveries`. Original worm/vote/typed ride, next-turn entry, end-Mentat held custody and end-Spice closing draw after a Nexus and settled alliance remain. Extra/ordinary revival ledgers and borrowed Guild nested payment preserve native identity and physical outcomes. Skills/native/HW/variants/previews and unresolved mixed-module Fremen Betrayal remain gated; no new universal interruption priority. | [Composition contract and bounded evidence](DISCOVERY_PROTOTYPE.md#classic-nexus-composition--5-october-2026), [Nexus source contract](NEXUS_CARD_RULES.md#classic-discovery--original-tech-and-strongholds--5-october-2026) |
| Discoveries | Preserve seven sourced Spice Cards/eight tokens, faction peeks and Collection choices. Fresh classic/native E1/E2/standalone E3 may retain original Tech3..6; separate fresh classic all14 Skills/Discovery/Tech uses original training, entry, movement, stash, rescue/rewards and Bureaucrat invoices. Native setup, revealed-only No-Fields and Moritani source histories remain. Advanced Orgiz uses the supplied p24 territory-Collection trigger; Basic stays provisional per deposit. Only Jacurutu adds a stronghold. Contested/shared/mixed cases, other combinations and public starts remain unfinished. | [Component authority](DISCOVERY_COMPONENTS.md), [native composition](DISCOVERY_PROTOTYPE.md#native-setup-and-original-consumers--5-october-2026), [classic Skills/Advanced source cutover](DISCOVERY_PROTOTYPE.md#classic-leader-skills-and-advanced-orgiz--5-october-2026) | | Discoveries | Preserve seven sourced Spice Cards/eight tokens, faction peeks and Collection choices. Fresh classic/native E1/E2/standalone E3 may retain original Tech3..6; separate fresh classic all14 Skills/Discovery/Tech uses original training, entry, movement, stash, rescue/rewards and Bureaucrat invoices. Native setup, revealed-only No-Fields and Moritani source histories remain. Advanced Orgiz uses the supplied p24 territory-Collection trigger; Basic stays provisional per deposit. Only Jacurutu adds a stronghold. Contested/shared/mixed cases, other combinations and public starts remain unfinished. | [Component authority](DISCOVERY_COMPONENTS.md), [native composition](DISCOVERY_PROTOTYPE.md#native-setup-and-original-consumers--5-october-2026), [classic Skills/Advanced source cutover](DISCOVERY_PROTOTYPE.md#classic-leader-skills-and-advanced-orgiz--5-october-2026) |
| Guild Secret Ally | One explicit Guild-priced ordinary shipment; borrowed prices/routes preserve native identity. Free Fremen reinforcement remains a separate choice. | [Route contract](NEXUS_GUILD_SECRET_ALLY_RULES.md), [verified runtime](NEXUS_GUILD_SECRET_ALLY_RUNTIME.md) | | Guild Secret Ally | One explicit Guild-priced ordinary shipment; borrowed prices/routes preserve native identity. Free Fremen reinforcement remains a separate choice. | [Route contract](NEXUS_GUILD_SECRET_ALLY_RULES.md), [verified runtime](NEXUS_GUILD_SECRET_ALLY_RUNTIME.md) |
| Homeworld custody and mobility | Physical home/visitor groups, population thresholds, tariffs and transport permissions retain distinct sources; Homeworld immunity is not universal immunity. | [Homeworld rules](HOMEWORLD_RULES.md), [benefits](HOMEWORLD_BENEFITS_RULES.md), [mobility](HOMEWORLD_MOBILITY_RULES.md), [invasion](HOMEWORLD_INVASION_RULES.md), [Guild transport](GUILD_HOMEWORLD_TRANSPORT_RULES.md) | | Homeworld custody and mobility | Physical home/visitor groups, population thresholds, tariffs and transport permissions retain distinct sources; Homeworld immunity is not universal immunity. | [Homeworld rules](HOMEWORLD_RULES.md), [benefits](HOMEWORLD_BENEFITS_RULES.md), [mobility](HOMEWORLD_MOBILITY_RULES.md), [invasion](HOMEWORLD_INVASION_RULES.md), [Guild transport](GUILD_HOMEWORLD_TRANSPORT_RULES.md) |
+22
View File
@@ -67,3 +67,25 @@ export function classicDiscoveryLeaderSkillsProfile(game: LeaderSkillProfile): b
} }
return true; return true;
} }
/** Original classic Nexus/Discovery, optionally Tech and Advanced Strongholds.
* Setup verifies undealt inventories; live token/encounter frames are expected. */
export function classicDiscoveryNexusProfile(game: FactionModuleProfile): boolean {
if (typeof game.advanced !== 'boolean' || game.discoveryEnabled !== true ||
!game.nexusCards || game.expansions.length || game.players.length < 2 ||
game.players.length > 6 || (game.techTokens && game.players.length < 3) ||
(game.strongholdCards && !game.advanced) || game.leaderSkills || game.homeworlds ||
game.ecazTreachery || game.semutaPreview || game.advancedPreview || game.kullPreview ||
game.nexusKullPreview || game.guildBetrayalPreview || game.richeseBetrayalPreview ||
game.nexusIxianReplacementPreview || game.nexusIxianBetrayalPreview ||
game.nexusHarkonnenBetrayalPreview) return false;
for (let i = 0; i < game.players.length; i++) {
const player = game.players[i];
for (let j = 0; j < i; j++) if (game.players[j].faction === player.faction) return false;
let classic = false;
for (const faction of FACTIONS)
if (faction.id === player.faction) { classic = faction.expansion === 'base'; break; }
if (!classic) return false;
}
return true;
}
+8 -3
View File
@@ -1,6 +1,6 @@
import { classicNexusModulesProfile, pairedNexusModulesProfile } from './nexus-module-profile'; import { classicNexusModulesProfile, pairedNexusModulesProfile } from './nexus-module-profile';
import { nativeFactionTechProfile } from './faction-module-profile'; import { nativeFactionTechProfile } from './faction-module-profile';
import { classicDiscoveryLeaderSkillsProfile, discoveryModeSupported, discoveryModuleProfile } from './discovery-module-profile'; import { classicDiscoveryLeaderSkillsProfile, classicDiscoveryNexusProfile, discoveryModeSupported, discoveryModuleProfile } from './discovery-module-profile';
import { advancedMoritaniLeaderSkillsProfile, advancedNativeLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, pairedNexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile } from './leader-skill-profile'; import { advancedMoritaniLeaderSkillsProfile, advancedNativeLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, pairedNexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile } from './leader-skill-profile';
import { bribeTimingBlock, maximumBribe, type BribeOptions } from './bribe-options'; import { bribeTimingBlock, maximumBribe, type BribeOptions } from './bribe-options';
import { quoteSpicePlacement, stormExposesTerritory, stormSectorAfter, wormConsumesForces } from './disaster-rules'; import { quoteSpicePlacement, stormExposesTerritory, stormSectorAfter, wormConsumesForces } from './disaster-rules';
@@ -8567,7 +8567,12 @@ export function initializeHomeworldOccupationGameForAudit(state: Game): Game {
export function initializeNexusGameForAudit(state: Game): Game { export function initializeNexusGameForAudit(state: Game): Game {
requireRule(!!state.nexusCards && state.nexusCards.cards === null && state.nexusCards.phase === null, requireRule(!!state.nexusCards && state.nexusCards.cards === null && state.nexusCards.phase === null,
'Enable Nexus cards in a fresh audit lobby first.'); 'Enable Nexus cards in a fresh audit lobby first.');
return initializeSetupGameForAudit(state, !!state.homeworlds, true); if (state.discoveryEnabled) {
requireRule(classicDiscoveryNexusProfile(state), 'Discovery Nexus requires a fresh classic lobby with optional original Tech and Advanced Strongholds.');
requireFreshBaseRuntime(state);
requireFreshFactionInventory(state);
}
return initializeSetupGameForAudit(state, !!state.homeworlds, true, false, classicDiscoveryNexusProfile(state));
} }
/** Fresh audit admission only; ordinary games and public starts never opt in. */ /** Fresh audit admission only; ordinary games and public starts never opt in. */
export function initializeHarkonnenNexusBetrayalGameForAudit(state: Game): Game { export function initializeHarkonnenNexusBetrayalGameForAudit(state: Game): Game {
@@ -9563,7 +9568,7 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
const skillTech = leaderSkills && (classicTechLeaderSkillsProfile(g) || nativeTechLeaderSkillsProfile(g) || skillStronghold || classicDiscoveryLeaderSkillsProfile(g)); const skillTech = leaderSkills && (classicTechLeaderSkillsProfile(g) || nativeTechLeaderSkillsProfile(g) || skillStronghold || classicDiscoveryLeaderSkillsProfile(g));
const nativeTech = factions && !leaderSkills && nativeFactionTechProfile(g); const nativeTech = factions && !leaderSkills && nativeFactionTechProfile(g);
const discoveryComposition = discovery && (discoveryModuleProfile(g) || const discoveryComposition = discovery && (discoveryModuleProfile(g) ||
(leaderSkills && classicDiscoveryLeaderSkillsProfile(g))); classicDiscoveryNexusProfile(g) || (leaderSkills && classicDiscoveryLeaderSkillsProfile(g)));
const nexusModules = nexus && (classicNexusModulesProfile(g) || pairedNexusModulesProfile(g) || const nexusModules = nexus && (classicNexusModulesProfile(g) || pairedNexusModulesProfile(g) ||
(leaderSkills && (classicNexusLeaderSkillsProfile(g) || pairedNexusLeaderSkillsProfile(g) || standaloneE3NexusLeaderSkillsProfile(g)))); (leaderSkills && (classicNexusLeaderSkillsProfile(g) || pairedNexusLeaderSkillsProfile(g) || standaloneE3NexusLeaderSkillsProfile(g))));
requireRule(!g.techTokens || ((skillTech || nativeTech || nexusModules || discoveryComposition) && requireRule(!g.techTokens || ((skillTech || nativeTech || nexusModules || discoveryComposition) &&
+10 -8
View File
@@ -2,6 +2,7 @@ import type { Game, Player } from './engine';
import { eliteRevivalRemaining } from './revival'; import { eliteRevivalRemaining } from './revival';
import { classicNexusModulesProfile } from './nexus-module-profile'; import { classicNexusModulesProfile } from './nexus-module-profile';
import { classicNexusLeaderSkillsProfile } from './leader-skill-profile'; import { classicNexusLeaderSkillsProfile } from './leader-skill-profile';
import { classicDiscoveryNexusProfile } from './discovery-module-profile';
export const EMPEROR_NEXUS_REVIVALS = 3; export const EMPEROR_NEXUS_REVIVALS = 3;
export type EmperorNexusPools = { export type EmperorNexusPools = {
@@ -65,14 +66,15 @@ export function emperorNexusModeSupported(g: Game): boolean {
p.faction, p.faction,
), ),
) && ) &&
!g.homeworlds && (classicDiscoveryNexusProfile(g) ||
(!g.leaderSkills || classicNexusLeaderSkillsProfile(g)) && (!g.homeworlds &&
!g.discoveryEnabled && (!g.leaderSkills || classicNexusLeaderSkillsProfile(g)) &&
!g.discoveries && !g.discoveryEnabled &&
!g.discoveryStash && !g.discoveries &&
!g.greatMaker && !g.discoveryStash &&
((!g.techTokens && !g.strongholdCards) || classicNexusModulesProfile(g) || !g.greatMaker &&
(!!g.leaderSkills && classicNexusLeaderSkillsProfile(g))) ((!g.techTokens && !g.strongholdCards) || classicNexusModulesProfile(g) ||
(!!g.leaderSkills && classicNexusLeaderSkillsProfile(g)))))
); );
} }
/** A fixed three-counter grant preserves the separate Advanced elite cap. */ /** A fixed three-counter grant preserves the separate Advanced elite cap. */
+2
View File
@@ -1,8 +1,10 @@
import { FACTIONS } from './catalog'; import { FACTIONS } from './catalog';
import type { FactionModuleProfile } from './faction-module-profile'; import type { FactionModuleProfile } from './faction-module-profile';
import { classicDiscoveryNexusProfile } from './discovery-module-profile';
/** Public classic configuration only; the initializer checks undealt physical components. */ /** Public classic configuration only; the initializer checks undealt physical components. */
export function classicNexusModulesProfile(game: FactionModuleProfile): boolean { export function classicNexusModulesProfile(game: FactionModuleProfile): boolean {
if (game.discoveryEnabled || game.discoveries) return classicDiscoveryNexusProfile(game);
if (typeof game.advanced !== 'boolean' || !game.nexusCards || if (typeof game.advanced !== 'boolean' || !game.nexusCards ||
(!game.techTokens && !game.strongholdCards) || game.expansions.length !== 0 || (!game.techTokens && !game.strongholdCards) || game.expansions.length !== 0 ||
game.players.length < 2 || game.players.length > 6 || game.players.length < 2 || game.players.length > 6 ||
+4 -2
View File
@@ -4081,6 +4081,7 @@ export const RULE_TOPICS: RuleTopic[] = [
steps: [ 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. Other modules, paired or mixed Ecaz/Moritani families and normal public starts stay gated.', '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. Other modules, paired or mixed Ecaz/Moritani families and normal public starts stay gated.',
'A separate fresh classic Leader Skills entry may explicitly add Discoveries, with the original base deck, all fourteen skill components and optional original Tech at three or more seats. Existing trained posture, physical free entry, paid nested routes, stash capacity and skill battle outcomes remain. Native skill families, Nexus, Strongholds, Homeworlds and separate Banker/Mentat previews are outside this composition.', 'A separate fresh classic Leader Skills entry may explicitly add Discoveries, with the original base deck, all fourteen skill components and optional original Tech at three or more seats. Existing trained posture, physical free entry, paid nested routes, stash capacity and skill battle outcomes remain. Native skill families, Nexus, Strongholds, Homeworlds and separate Banker/Mentat previews are outside this composition.',
'A separate fresh classic Nexus entry may explicitly add Discoveries, original Tech at three or more seats and Advanced Stronghold Cards. Original end-Mentat ownership and next-turn physical entry remain. Great Maker finishes its worm, vote and typed reserve ride before the ordinary Nexus settlement; cards are offered only at the end of the entire Spice Blow phase after a Nexus and settled alliance, not immediately or at Mentat. Source-clear extra or ordinary free revival and Guild-priced nested shipment retain native pieces, ledgers and actual battle rewards. Skills, native families, Homeworlds, unrelated previews and unresolved mixed-module Fremen Betrayal stay separate.',
'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.', '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.', '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.',
'Fremen privately see placed Hiereg tokens; Guild privately sees placed Smuggler tokens. During Spice Collection, non-advisor forces in a token’s surrounding territory may inspect it privately, then choose whether to reveal it. The Discovery panel shows only authorized faces. Reading an already visible token’s rules does not reveal or use it.', 'Fremen privately see placed Hiereg tokens; Guild privately sees placed Smuggler tokens. During Spice Collection, non-advisor forces in a token’s surrounding territory may inspect it privately, then choose whether to reveal it. The Discovery panel shows only authorized faces. Reading an already visible token’s rules does not reveal or use it.',
@@ -4119,8 +4120,8 @@ export const RULE_TOPICS: RuleTopic[] = [
}, },
{ {
area: 'Verification', status: 'Partial', area: 'Verification', status: 'Partial',
detail: 'Native and classic-skill original programmes/phone controls cover setup, physical entry, nested tariff/No-Field arrival, stash capacity, skill movement/rescue, reward ordering and Moritani assassination. Controlled Collection shows Basic three versus Advanced two transfers; two genuine six-seat Skills/Discovery/Tech games finish on one unchanged tree. Native five-game repair/resume evidence remains separate. These are targeted proofs, not natural-every-effect, full combinations, assurance or deployed acceptance.', detail: 'Native, classic-skill and classic Nexus original programmes/phone controls cover setup, physical entry, nested tariff/No-Field arrival, stash capacity, skill movement/rescue, reward ordering and Moritani assassination. Classic Nexus proof includes Great Maker typed reserve ride, actual alliance settlement and one closing end-Spice draw releasing Charity, separate revival ledgers and nested battle/Tech. Two genuine six-seat Nexus Discovery games finish 1,150 accepted actions without rejection; Skills and native repair/resume evidence remain separate. These are targeted proofs, not natural-every-effect, full combinations, assurance or deployed acceptance.',
evidence: ['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'], evidence: ['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'],
}, },
], ],
}, },
@@ -4401,6 +4402,7 @@ 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.', '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.', 'Use Cunning for your own faction, Betrayal when another player controls the printed faction, and Secret Ally when that faction is absent. Used cards go to the separate Nexus discard pile. Each printed effect has its own timing.',
'Fresh local classic Nexus tables may preserve original Tech Tokens in Basic/Advanced three-through-six-seat lobbies and/or Stronghold Cards in Advanced two-through-six-seat lobbies; selecting both requires three or more. Original base33/all12 setup, closing Nexus deals, first-Storm Tech and actual end-Mentat Stronghold custody remain. Skills, Homeworlds in this composition, native expansions, unrelated previews, public starts and saved-game conversion stay separate.', 'Fresh local classic Nexus tables may preserve original Tech Tokens in Basic/Advanced three-through-six-seat lobbies and/or Stronghold Cards in Advanced two-through-six-seat lobbies; selecting both requires three or more. Original base33/all12 setup, closing Nexus deals, first-Storm Tech and actual end-Mentat Stronghold custody remain. Skills, Homeworlds in this composition, native expansions, unrelated previews, public starts and saved-game conversion stay separate.',
'Fresh classic nexus --discoveries also preserves those original Tech and Advanced Stronghold prerequisites. The original seven Discovery Spice Cards/eight tokens, protected nested locations, next-turn physical entry and Great Maker worm/vote/typed ride remain. The actual closing draw follows both Advanced piles and settled alliances. Source-clear revival quotas and borrowed Guild nested tariffs compose existing controls; unresolved mixed-module Fremen Betrayal and other unfinished effects stay gated. No public start or saved-game conversion is granted.',
'The same fresh local Nexus entry also composes Tech and/or Advanced Strongholds with paired Ixians + Tleilaxu OR CHOAM + Richese and classic opponents, one original family deck and no Skills. Tech needs three through six seats; Strongholds needs two through six Advanced seats. Real closing draws, native typed forces, physical Face Dancers and No-Fields, original payments/rewards and held effects remain. Mixed families, E3, Homeworlds, unrelated previews, public starts and unresolved rulings are not opened.', 'The same fresh local Nexus entry also composes Tech and/or Advanced Strongholds with paired Ixians + Tleilaxu OR CHOAM + Richese and classic opponents, one original family deck and no Skills. Tech needs three through six seats; Strongholds needs two through six Advanced seats. Real closing draws, native typed forces, physical Face Dancers and No-Fields, original payments/rewards and held effects remain. Mixed families, E3, Homeworlds, unrelated previews, public starts and unresolved rulings are not opened.',
'The separate fresh local leader-skills --nexus-cards entry combines classic base33/all14 Skills/all12 Nexus in Basic or Advanced, two through six seats. Selected original Tech Tokens require three or more seats; Stronghold Cards require Advanced. Original starting hands/offers, 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. Other overlays and public starts remain guarded.', 'The separate fresh local leader-skills --nexus-cards entry combines classic base33/all14 Skills/all12 Nexus in Basic or Advanced, two through six seats. Selected original Tech Tokens require three or more seats; Stronghold Cards require Advanced. Original starting hands/offers, 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. Other 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 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.',
@@ -0,0 +1,292 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { emperorNexusModeSupported } from '../game/nexus-emperor-secret-ally';
import { nexusEmperorRevivalAction } from '../game/nexus-emperor-secret-ally-options';
import { nexusFremenRevivalAction } from '../game/nexus-fremen-revival-options';
import { forceRevivalQuote, forceRevivalRemaining } from '../game/revival';
import { quoteNexusGuildSecretShipment } from '../game/nexus-guild-secret-ally';
import { ownedTech } from '../game/tech-tokens';
import { territory } from '../game/board';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
import { holdNexusSkillsModulesPaymentsCard } from './fixture-nexus-skills-modules-payments';
import {
advanceDiscoveryClassicNexusEffects, advanceDiscoveryClassicNexusEffectsToPhase,
createDiscoveryClassicNexusEffectsFixture, discoveryClassicNexusEffectsInventory,
discoveryClassicNexusEffectsPlayer, finishDiscoveryClassicNexusEffectsInteraction,
finishDiscoveryClassicNexusNestedBattle, initializeDiscoveryClassicNexusEffectsSetup,
openDiscoveryClassicNexusNestedBattle, payDiscoveryClassicNexusEffectsFixture,
type DiscoveryClassicNexusEffectsFixture,
} from './fixture-discovery-classic-nexus-effects';
const wallets = (game: Game) => Object.fromEntries(game.players.map(player => [player.id, player.spice]));
function rejected(game: Game, actor: string, action: Action): void {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before, 'Rejected declarations retain original physical cards, counters and payment ledgers');
}
function legalPolicies(game: Game, actor: string): void {
assert.equal(DIFFICULTIES.length, 4);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, actor);
view.players.find(player => player.id === actor)!.bot = difficulty;
const actions = botActions(view);
assert.ok(actions.length > 0, `${difficulty} must expose an original legal continuation`);
for (const action of actions) discoveryClassicNexusEffectsInventory(applyAction(structuredClone(game), actor, action));
}
}
function sourceTransitions(fixture: DiscoveryClassicNexusEffectsFixture): void {
const revealer = fixture.kind === 'guild-shipment' ? fixture.opponent : fixture.actor;
const beforeToken = fixture.beforeReveal.discoveries!.tokens.find(token => token.id === fixture.token)!;
const afterToken = fixture.afterReveal.discoveries!.tokens.find(token => token.id === fixture.token)!;
assert.equal(beforeToken.face, 'shrine'); assert.equal(beforeToken.revealedTurn, null);
assert.equal(afterToken.revealedTurn, 1);
assert.equal(fixture.beforeEntry.turn, 2); assert.equal(fixture.beforeEntry.phase, 0);
const before = discoveryClassicNexusEffectsPlayer(fixture.beforeEntry, revealer);
const after = discoveryClassicNexusEffectsPlayer(fixture.afterEntry, revealer);
assert.equal(before.forces[fixture.source], 2); assert.equal(after.forces[fixture.source] ?? 0, 0);
assert.equal(after.forces['shrine:0'], 2);
assert.equal(after.reserves, before.reserves); assert.equal(after.tanks, before.tanks);
assert.equal(after.spice, before.spice); assert.equal(after.shipped, before.shipped); assert.equal(after.moved, before.moved);
assert.deepEqual(fixture.afterEntry.techTokens, fixture.beforeEntry.techTokens, 'Free next-turn entry does not trigger paid shipping industry');
assert.equal(fixture.beforeClosingDraw.nexusCards!.phase!.stage, 'drawing');
assert.equal(fixture.beforeClosingDraw.players.find(player => player.id === fixture.actor)!.ally, null);
assert.equal(fixture.afterClosingDraw.nexusCards!.cards!.hands[fixture.actor],
fixture.kind === 'guild-shipment' ? 'guild' : fixture.kind === 'fremen-revival' ? 'fremen' : 'emperor');
discoveryClassicNexusEffectsInventory(fixture.game);
}
function unchangedCustody(before: Game, after: Game): void {
assert.deepEqual(after.discoveries, before.discoveries);
assert.deepEqual(after.strongholdCards, before.strongholdCards);
for (const player of before.players) assert.deepEqual(viewGame(after, player.id).discoveries, viewGame(before, player.id).discoveries);
discoveryClassicNexusEffectsInventory(after);
}
const configurations = [
{ name: 'Basic', advanced: false, tech: false, strongholds: false },
{ name: 'Basic Tech', advanced: false, tech: true, strongholds: false },
{ name: 'Advanced', advanced: true, tech: false, strongholds: false },
{ name: 'Advanced Tech', advanced: true, tech: true, strongholds: false },
{ name: 'Advanced Strongholds', advanced: true, tech: false, strongholds: true },
{ name: 'Advanced Tech/Strongholds', advanced: true, tech: true, strongholds: true },
] as const;
for (const configuration of configurations) for (const kind of ['emperor-revival', 'fremen-revival'] as const) {
void test(`${configuration.name} classic Discovery/Nexus ${kind} uses the real source, separate quota and once-only phase-end industry`, () => {
const fixture = createDiscoveryClassicNexusEffectsFixture({ ...configuration, kind });
sourceTransitions(fixture);
if (configuration.strongholds) assert.equal(fixture.afterEntry.strongholdCards!.owners.arrakeen, fixture.actor);
const privateView = viewGame(fixture.game, fixture.actor);
const control = kind === 'emperor-revival' ? nexusEmperorRevivalAction(privateView, 0) : nexusFremenRevivalAction(privateView, 0);
assert.deepEqual(control, fixture.paymentAction, 'Existing human controls bind the actual private physical card offer');
assert.equal(kind === 'emperor-revival' ? viewGame(fixture.game, fixture.opponent).nexusEmperorSecretAlly
: viewGame(fixture.game, fixture.opponent).nexusFremenRevival, null);
legalPolicies(fixture.game, fixture.actor);
rejected(fixture.game, fixture.opponent, fixture.paymentAction);
rejected(fixture.game, fixture.actor, { ...fixture.paymentAction, event: 'stale' });
rejected(fixture.game, fixture.actor, { ...fixture.paymentAction, bonus: true });
const before = discoveryClassicNexusEffectsPlayer(fixture.game, fixture.actor);
let paid = payDiscoveryClassicNexusEffectsFixture(fixture, structuredClone(fixture.game));
const after = discoveryClassicNexusEffectsPlayer(paid, fixture.actor);
assert.equal(after.reserves, before.reserves + 3); assert.equal(after.tanks, before.tanks - 3);
assert.deepEqual(wallets(paid), wallets(fixture.game));
assert.equal(after.revived, kind === 'fremen-revival' ? 3 : before.revived);
assert.equal(after.freeForcesRevived ?? 0, kind === 'fremen-revival' ? 3 : before.freeForcesRevived ?? 0);
assert.equal(forceRevivalRemaining(paid, after), kind === 'fremen-revival' ? 0 : 3);
assert.equal(paid.nexusCards!.cards!.hands[fixture.actor], null);
const card = kind === 'emperor-revival' ? 'emperor' : 'fremen';
assert.equal(paid.nexusCards!.cards!.discard.filter(value => value === card).length, 1);
const history = kind === 'emperor-revival' ? paid.nexusEmperorSecretHistory! : paid.nexusFremenRevivalHistory!;
assert.equal(history.length, 1); assert.equal(history[0].owner, fixture.actor);
assert.equal(history[0].turn, 2); assert.equal(history[0].phase, 4);
assert.equal(history[0].before.tanks - history[0].after.tanks, 3);
assert.equal(history[0].before.spice, history[0].after.spice);
unchangedCustody(fixture.game, paid); rejected(paid, fixture.actor, fixture.paymentAction);
if (kind === 'emperor-revival') {
const quote = forceRevivalQuote(paid, after, 3);
assert.equal(quote.cost, 2);
paid = finishDiscoveryClassicNexusEffectsInteraction(applyAction(paid, fixture.actor, { type: 'revive', amount: 3 }));
const ordinary = discoveryClassicNexusEffectsPlayer(paid, fixture.actor);
assert.equal(ordinary.reserves, before.reserves + 6); assert.equal(ordinary.tanks, before.tanks - 6);
assert.equal(ordinary.revived, 3); assert.equal(ordinary.freeForcesRevived, 2);
assert.equal(ordinary.spice, before.spice - 2);
} else rejected(paid, fixture.actor, { type: 'revive', amount: 1 });
if (configuration.tech) {
assert.equal(paid.techTokens!.axlotl.spice, fixture.tokenCount);
assert.equal(paid.techTokens!.axlotl.triggeredTurn, 2);
}
const expected = wallets(paid);
if (fixture.tokenOwner) expected[fixture.tokenOwner] += fixture.tokenCount;
const movement = advanceDiscoveryClassicNexusEffectsToPhase(paid, 5);
assert.deepEqual(wallets(movement), expected, 'Only real Revival-end Axlotl credit becomes spendable');
if (configuration.tech) assert.equal(movement.techTokens!.axlotl.spice, 0);
const completedMovement = advanceDiscoveryClassicNexusEffectsToPhase(movement, 6);
const afterMovement = { ...expected };
if (completedMovement.phase >= 7)
for (const receipt of quoteSpiceCollection(movement).receipts) afterMovement[receipt.player] += receipt.strongholds + receipt.collected;
assert.deepEqual(wallets(completedMovement), afterMovement, 'Only independently quoted Collection can accompany an auto-skipped empty Battle; Axlotl cannot pay twice');
const nextTurn = advanceDiscoveryClassicNexusEffects(completedMovement, game => game.turn === 3);
const nextRevival = advanceDiscoveryClassicNexusEffectsToPhase(nextTurn, 4);
assert.equal(discoveryClassicNexusEffectsPlayer(nextRevival, fixture.actor).revived, 0);
assert.equal(discoveryClassicNexusEffectsPlayer(nextRevival, fixture.actor).freeForcesRevived ?? 0, 0);
assert.deepEqual(kind === 'emperor-revival' ? nextRevival.nexusEmperorSecretHistory : nextRevival.nexusFremenRevivalHistory, history);
rejected(nextRevival, fixture.actor, fixture.paymentAction);
discoveryClassicNexusEffectsInventory(nextRevival);
});
}
void test('Advanced Discovery Emperor extra-three and Fremen free-three retain actual native elite subsets and the shared one-elite cap', () => {
for (const kind of ['emperor-revival', 'fremen-revival'] as const) {
const fixture = createDiscoveryClassicNexusEffectsFixture({ kind, advanced: true, tech: true, strongholds: true,
ownerFaction: kind === 'emperor-revival' ? 'fremen' : 'emperor', eliteTanks: 2 });
const view = viewGame(fixture.game, fixture.actor);
assert.deepEqual(kind === 'emperor-revival' ? view.nexusEmperorSecretAlly!.revival.eliteOptions : view.nexusFremenRevival!.eliteOptions, [0, 1]);
rejected(fixture.game, fixture.actor, { ...fixture.paymentAction, elite: 2 });
legalPolicies(fixture.game, fixture.actor);
const before = discoveryClassicNexusEffectsPlayer(fixture.game, fixture.actor);
const paid = payDiscoveryClassicNexusEffectsFixture(fixture);
const after = discoveryClassicNexusEffectsPlayer(paid, fixture.actor);
assert.equal(after.elites!.reserves, before.elites!.reserves + 1);
assert.equal(after.elites!.tanks, before.elites!.tanks - 1); assert.equal(after.elites!.revived, 1);
assert.equal(after.reserves, before.reserves + 3); assert.equal(after.tanks, before.tanks - 3);
assert.deepEqual(wallets(paid), wallets(fixture.game));
rejected(paid, fixture.actor, { type: 'revive', amount: 1, elite: 1 });
unchangedCustody(fixture.game, paid);
}
});
void test('Discovery ordinary-first Emperor return is extra; ordinary-first Fremen is genuinely late and still rejected', () => {
const emperor = createDiscoveryClassicNexusEffectsFixture({ advanced: true, tech: true, strongholds: true });
const ordinary = finishDiscoveryClassicNexusEffectsInteraction(applyAction(emperor.game, emperor.actor, { type: 'revive', amount: 3 }));
const paid = payDiscoveryClassicNexusEffectsFixture(emperor, ordinary);
const before = discoveryClassicNexusEffectsPlayer(ordinary, emperor.actor), after = discoveryClassicNexusEffectsPlayer(paid, emperor.actor);
assert.equal(after.reserves, before.reserves + 3); assert.equal(after.tanks, before.tanks - 3);
assert.equal(after.spice, before.spice); assert.equal(after.revived, 3); assert.equal(after.freeForcesRevived, 2);
assert.deepEqual(paid.techTokens, ordinary.techTokens);
const fremen = createDiscoveryClassicNexusEffectsFixture({ kind: 'fremen-revival', advanced: true, tech: true });
const late = finishDiscoveryClassicNexusEffectsInteraction(applyAction(fremen.game, fremen.actor, { type: 'revive', amount: 1 }));
assert.match(viewGame(late, fremen.actor).nexusFremenRevival!.blocked ?? '', /before ordinary force revivals/);
rejected(late, fremen.actor, fremen.paymentAction);
for (const kind of ['emperor-revival', 'fremen-revival'] as const) {
const short = createDiscoveryClassicNexusEffectsFixture({ kind, tanks: 2 });
rejected(short.game, short.actor, short.paymentAction);
assert.equal(discoveryClassicNexusEffectsPlayer(short.game, short.actor).tanks, 2);
}
});
void test('an earned Discovery/Nexus Axlotl pile cannot settle through an unsettled real printed Truthtrance', () => {
const fixture = createDiscoveryClassicNexusEffectsFixture({ advanced: true, tech: true, strongholds: true });
let game = payDiscoveryClassicNexusEffectsFixture(fixture);
const card = holdNexusSkillsModulesPaymentsCard(game, fixture.opponent, 'truthtrance', fixture.staging);
const before = structuredClone(game);
game = applyAction(game, fixture.opponent, { type: 'card', card: card.id });
assert.ok(game.truthtrance); assert.equal(game.phase, 4);
assert.deepEqual(wallets(game), wallets(before)); assert.equal(game.techTokens!.axlotl.spice, fixture.tokenCount);
for (const player of game.players) rejected(game, player.id, { type: 'ready' });
legalPolicies(game, game.players.find(player => !game.truthtrance!.passed.includes(player.id))!.id);
const settled = finishDiscoveryClassicNexusEffectsInteraction(game);
assert.equal(settled.truthtrance ?? null, null); assert.equal(settled.phase, 4);
assert.deepEqual(wallets(settled), wallets(before)); assert.equal(settled.techTokens!.axlotl.spice, fixture.tokenCount);
assert.ok(settled.discard.some(value => value.id === card.id && value.effect === 'truthtrance'));
discoveryClassicNexusEffectsInventory(settled);
});
for (const configuration of [configurations[0], configurations[1], configurations[4], configurations[5]]) {
void test(`${configuration.name} paid Guild Secret Ally nested Shrine arrival retains invoice/source identity and original battle reward once`, () => {
const fixture = createDiscoveryClassicNexusEffectsFixture({ ...configuration, kind: 'guild-shipment' });
sourceTransitions(fixture); legalPolicies(fixture.game, fixture.actor);
const before = discoveryClassicNexusEffectsPlayer(fixture.game, fixture.actor);
const quote = quoteNexusGuildSecretShipment(territory('shrine').type, 3);
assert.equal(quote.cost, 2, 'A nested revealed location uses the printed stronghold shipping tariff, not a desert or native-free tariff');
rejected(fixture.game, fixture.opponent, fixture.paymentAction);
rejected(fixture.game, fixture.actor, { ...fixture.paymentAction, nexus: 'stale' });
rejected(fixture.game, fixture.actor, { ...fixture.paymentAction, smuggler: true });
const paid = finishDiscoveryClassicNexusEffectsInteraction(payDiscoveryClassicNexusEffectsFixture(fixture));
const arrived = discoveryClassicNexusEffectsPlayer(paid, fixture.actor);
assert.equal(arrived.spice, before.spice - quote.cost);
assert.equal(arrived.reserves, before.reserves - 3); assert.equal(arrived.forces['shrine:0'], 3);
assert.equal(arrived.shipped, true); assert.equal(arrived.moved, before.moved);
for (const player of paid.players) if (player.id !== fixture.actor) assert.equal(player.spice, discoveryClassicNexusEffectsPlayer(fixture.game, player.id).spice);
assert.equal(paid.nexusCards!.cards!.hands[fixture.actor], null);
assert.equal(paid.nexusCards!.cards!.discard.filter(card => card === 'guild').length, 1);
assert.equal(paid.nexusGuildSecretHistory!.length, 1);
const record = paid.nexusGuildSecretHistory![0], frame = JSON.parse(record.frame);
assert.equal(record.receipt.owner, fixture.actor); assert.equal(record.receipt.turn, 2); assert.equal(record.kind, 'reserve');
assert.equal(frame.player, fixture.actor); assert.equal(frame.territory, 'shrine'); assert.equal(frame.sector, 0);
assert.equal(frame.amount, 3); assert.equal(frame.elite, 0); assert.equal(frame.cost, quote.cost); assert.equal(frame.allyPayment, 0);
assert.equal(frame.guildSecretEvent, fixture.paymentAction.nexus);
assert.equal(frame.nexusEvent, undefined); assert.equal(frame.guildNexusEvent, undefined); assert.equal(frame.source, undefined);
unchangedCustody(fixture.game, paid); rejected(paid, fixture.actor, fixture.paymentAction);
if (configuration.tech) {
assert.equal(paid.techTokens!.heighliners.spice, fixture.tokenCount);
assert.equal(paid.techTokens!.heighliners.triggeredTurn, 2);
}
const expected = wallets(paid);
if (fixture.tokenOwner) expected[fixture.tokenOwner] += fixture.tokenCount;
const battle = openDiscoveryClassicNexusNestedBattle(fixture, paid);
assert.deepEqual(wallets(battle.beforeBattle), expected, 'The original Movement-end industry pays once before the actual nested battle');
if (configuration.tech) assert.equal(battle.beforeBattle.techTokens!.heighliners.spice, 0);
assert.equal(battle.game.battle!.territory, 'shrine');
const loser = discoveryClassicNexusEffectsPlayer(battle.game, fixture.opponent);
const bounty = loser.leaders.find(leader => leader.id === battle.losingLeader)!.strength;
const priorTokenOwners = battle.game.techTokens && Object.fromEntries(Object.entries(battle.game.techTokens).map(([id, token]) => [id, token.owner]));
const collection = quoteSpiceCollection(battle.beforeBattle);
const done = finishDiscoveryClassicNexusNestedBattle(battle);
assert.equal(done.lastBattleContext!.winner, fixture.actor);
assert.equal(discoveryClassicNexusEffectsPlayer(done, fixture.actor).forces['shrine:0'], 3);
assert.equal(discoveryClassicNexusEffectsPlayer(done, fixture.opponent).forces['shrine:0'] ?? 0, 0);
assert.equal(discoveryClassicNexusEffectsPlayer(done, fixture.opponent).tanks, loser.tanks + 2);
const battleWallets = { ...expected };
battleWallets[fixture.actor] += bounty;
if (done.phase >= 7)
for (const receipt of collection.receipts) battleWallets[receipt.player] += receipt.strongholds + receipt.collected;
assert.deepEqual(wallets(done), battleWallets, 'Only the original leader bounty and independently quoted native Collection may accompany battle completion');
assert.equal(discoveryClassicNexusEffectsPlayer(done, fixture.opponent).leaders.find(leader => leader.id === battle.losingLeader)!.dead, true);
assert.ok(discoveryClassicNexusEffectsPlayer(done, fixture.actor).hand.some(card => card.id === battle.weapon.id));
assert.ok(!done.discard.some(card => card.id === battle.weapon.id));
assert.deepEqual(done.nexusGuildSecretHistory, paid.nexusGuildSecretHistory);
assert.deepEqual(done.discoveries, paid.discoveries); assert.deepEqual(done.strongholdCards, paid.strongholdCards);
if (configuration.tech) {
assert.equal(ownedTech(done.techTokens, fixture.opponent).length, 0);
for (const [id, owner] of Object.entries(priorTokenOwners!))
assert.equal(done.techTokens![id as keyof NonNullable<Game['techTokens']>].owner, owner === fixture.opponent ? fixture.actor : owner);
}
discoveryClassicNexusEffectsInventory(done);
const atMentat = wallets(done);
if (done.phase < 7)
for (const receipt of collection.receipts) atMentat[receipt.player] += receipt.strongholds + receipt.collected;
const mentat = advanceDiscoveryClassicNexusEffectsToPhase(done, 8);
assert.deepEqual(wallets(mentat), atMentat, 'Only original city Collection pays; Shrine cannot recollect shipment industry or leader bounty');
assert.deepEqual(mentat.nexusGuildSecretHistory, paid.nexusGuildSecretHistory);
discoveryClassicNexusEffectsInventory(mentat);
});
}
void test('authenticated native Nexus/Discovery setup preserves original seat IDs/dealt custody and excludes unrelated Emperor module profiles', () => {
const initial = initializeDiscoveryClassicNexusEffectsSetup({ advanced: true, tech: true, strongholds: true,
seatIds: ['authenticated-owner', 'authenticated-opponent', 'authenticated-third'] });
const saved = structuredClone(initial);
const accepted = initializeDiscoveryClassicNexusEffectsSetup({ initial });
assert.deepEqual(accepted, saved);
const fixture = createDiscoveryClassicNexusEffectsFixture({ initial });
assert.equal(fixture.actor, 'authenticated-owner'); assert.deepEqual(fixture.initial, saved);
assert.deepEqual(fixture.game.players.map(player => player.id), initial.players.map(player => player.id));
assert.deepEqual(initial, saved);
assert.equal(emperorNexusModeSupported(fixture.game), true);
// Explicit adversarial compositions, not histories claimed to be natural play.
for (const change of [
(game: Game) => { game.expansions = ['ix']; },
(game: Game) => { game.players.find(player => player.id === fixture.opponent)!.faction = 'emperor'; },
(game: Game) => { game.advanced = false; },
(game: Game) => { game.semutaPreview = true; },
]) {
const invalid = structuredClone(fixture.game); change(invalid);
assert.equal(emperorNexusModeSupported(invalid), false);
rejected(invalid, fixture.actor, fixture.paymentAction);
}
const paid = payDiscoveryClassicNexusEffectsFixture(fixture);
const tampered = structuredClone(paid); tampered.nexusEmperorSecretHistory![0].after.revived++;
rejected(tampered, fixture.actor, { type: 'ready' });
discoveryClassicNexusEffectsInventory(paid);
});
@@ -0,0 +1,373 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { baseDeck } from '../game/cards';
import { territory } from '../game/board';
import { DIFFICULTIES } from '../game/bot-profiles';
import { botActions } from '../game/bots';
import { validateNexusCards } from '../game/nexus-cards';
import { validateDiscoveryState } from '../game/discoveries';
import { greatMakerRideAction } from '../game/great-maker-options';
import { quoteEcazInquiry } from '../game/nexus-ecaz-inquiry';
import { nexusCleanPlayBlocked } from '../game/nexus-play-boundary';
import { strongholdControllers } from '../game/stronghold-cards';
import {
advanceClassicDiscoveryNexus, applyClassicDiscoveryNexusStep, classicDiscoveryNexusClean,
classicDiscoveryNexusPlayer, createClassicDiscoveryNexusFixture, enterClassicDiscoveryNexus,
finishClassicDiscoveryNexusMentat, formClassicDiscoveryNexusAlliance, nextClassicDiscoveryNexusStep,
openClassicDiscoveryNexusAlliance, openClassicDiscoveryNexusEncounter, revealClassicDiscoveryNexus,
voteClassicDiscoveryNexus, type ClassicDiscoveryNexusFixture,
} from './fixture-discovery-classic-nexus';
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
function custody(game: Game): void {
validateNexusCards(game.nexusCards!.cards!, game.players);
validateDiscoveryState(game.discoveries!);
assert.deepEqual([...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand)]
.map(card => card.id).sort(), baseDeck().map(card => card.id).sort());
for (const player of game.players) {
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, amount) => sum + amount, 0), 20);
if (player.elites) assert.equal(player.elites.reserves + player.elites.tanks +
Object.values(player.elites.forces).reduce((sum, amount) => sum + amount, 0), player.faction === 'emperor' ? 5 : 3);
}
assert.ok(!(game.discoveryEntry && game.nexusCards!.phase?.stage === 'drawing'));
assert.ok(!(game.greatMaker?.stage === 'vote' && game.nexusCards!.phase?.stage === 'drawing'));
}
function rejectUnchanged(game: Game, actor: string, action: Action): void {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
/** Real majority, optional reserve-ride decline, real negotiated alliance, then
* the single source-closing deal. Only the remaining physical Nexus deck order
* is positioned before that deal; no card is transferred into a hand. */
function closingDraw(fixture: ClassicDiscoveryNexusFixture): Game {
let game = openClassicDiscoveryNexusEncounter(fixture);
game = voteClassicDiscoveryNexus(game, game.players.map(() => true));
game = openClassicDiscoveryNexusAlliance(game);
assert.ok(fixture.fremen);
game = formClassicDiscoveryNexusAlliance(game, fixture.opponent, fixture.fremen);
const deck = game.nexusCards!.cards!.deck;
const index = deck.indexOf('ecaz'); assert.ok(index >= 0);
deck.unshift(deck.splice(index, 1)[0]);
game = advanceClassicDiscoveryNexus(game, state => state.nexusCards!.phase?.stage === 'drawing');
assert.equal(game.phase, 1);
assert.equal(game.nexus, false);
assert.equal(game.discoveryEntry, undefined);
assert.equal(game.greatMaker?.stage, 'complete');
assert.deepEqual(game.nexusCards!.phase!.eligible, [fixture.collector]);
assert.deepEqual(viewGame(game, fixture.opponent).nexusCards!.choices, []);
const draw: Action = { type: 'nexusCardChoice', turn: game.turn, card: null, choice: 'draw', ownRedraws: 0 };
const before = structuredClone(game), owner = classicDiscoveryNexusPlayer(game, fixture.collector);
game = applyClassicDiscoveryNexusStep(game, { actor: fixture.collector, action: draw });
assert.equal(game.phase, 2, 'Completing the original closing draw must release the next phase.');
assert.equal(game.nexusCards!.phase!.stage, 'complete');
assert.equal(game.nexusCards!.cards!.hands[fixture.collector], 'ecaz');
assert.equal(classicDiscoveryNexusPlayer(game, fixture.collector).spice, owner.spice);
assert.equal(game.nexusCards!.cards!.deck.length, before.nexusCards!.cards!.deck.length - 1);
assert.deepEqual(game.wormRides, []);
rejectUnchanged(game, fixture.collector, draw);
custody(game);
return game;
}
function secondCollection(fixture: ClassicDiscoveryNexusFixture, game: Game): Game {
const shrine = game.discoveries!.tokens.find(token => token.face === 'shrine' && token.status === 'placed');
assert.ok(shrine, 'The explicit original second Hiereg supply lottery must place Shrine.');
game = advanceClassicDiscoveryNexus(game, state => state.phase === 5 && state.active === fixture.collector && classicDiscoveryNexusClean(state));
const sector = territory(shrine.territory!).sectors.find(value => value !== game.storm);
assert.notEqual(sector, undefined);
game = applyClassicDiscoveryNexusStep(game, { actor: fixture.collector,
action: { type: 'ship', territory: shrine.territory!, sector: sector!, amount: 1, elite: 0, allyPayment: 0 } });
game = advanceClassicDiscoveryNexus(game, state => state.phase === 7 && classicDiscoveryNexusClean(state));
return revealClassicDiscoveryNexus(game, fixture.collector, shrine.id);
}
void test('original staged Nexus/Discovery is playable from Basic and Advanced two-through-six seats through Mentat and free entry', () => {
for (const advanced of [false, true]) for (const seats of [2, 3, 4, 5, 6] as const) {
const fixture = createClassicDiscoveryNexusFixture({ advanced, seats, tech: seats >= 3, strongholds: advanced && seats >= 3 });
assert.equal(fixture.setup.status, 'setup');
assert.equal(fixture.setup.players.length, seats);
assert.equal(fixture.setup.spiceDeck.length, 28);
assert.equal(fixture.setup.discoveries!.tokens.length, 8);
custody(fixture.setup);
assert.equal(fixture.collection.turn, 1);
assert.equal(classicDiscoveryNexusPlayer(fixture.collection, fixture.collector).forces[`${fixture.parent}:${fixture.parentSector}`], 3);
assert.equal(fixture.firstMentat.before.phase, 8);
assert.equal(fixture.firstMentat.after.turn, 2);
assert.equal(fixture.game.phase, 0);
assert.equal(fixture.game.discoveryEntry?.stage, 'choose');
assert.equal(fixture.game.storm, fixture.firstMentat.before.storm, 'Free entry precedes original Storm movement.');
assert.equal(fixture.game.nexusCards!.phase?.stage, 'complete');
if (fixture.game.strongholdCards) {
assert.equal(fixture.firstMentat.before.strongholdCards!.claimedTurn, 0);
assert.equal(fixture.game.strongholdCards.claimedTurn, 1);
assert.deepEqual(fixture.game.strongholdCards.owners, strongholdControllers(fixture.game.players, false));
assert.equal(fixture.game.strongholdCards.owners.tueks_sietch, fixture.collector);
assert.equal(fixture.game.strongholdCards.owners.sietch_tabr, fixture.fremen);
}
const before = classicDiscoveryNexusPlayer(fixture.game, fixture.collector);
const entered = enterClassicDiscoveryNexus(reload(fixture.game), fixture.collector);
const after = classicDiscoveryNexusPlayer(entered, fixture.collector);
assert.equal(after.forces['cistern:0'], 1);
assert.equal(after.forces[`${fixture.parent}:${fixture.parentSector}`], 2);
assert.equal(after.spice, before.spice);
assert.equal(after.shipped, before.shipped);
assert.equal(after.moved, before.moved);
assert.equal(entered.discoveryEntry, undefined);
const encounter = advanceClassicDiscoveryNexus(entered, game => game.greatMaker?.stage === 'vote');
assert.equal(encounter.turn, 2);
assert.equal(classicDiscoveryNexusPlayer(encounter, fixture.opponent).forces[fixture.wormSource], undefined);
assert.equal(classicDiscoveryNexusPlayer(encounter, fixture.opponent).tanks, 3);
assert.equal(encounter.spice[fixture.wormSource], undefined);
assert.equal(classicDiscoveryNexusPlayer(encounter, fixture.collector).forces['cistern:0'], 1);
assert.deepEqual(encounter.wormRides, []);
custody(encounter);
const refused = voteClassicDiscoveryNexus(encounter, encounter.players.map(() => false));
const mentat = finishClassicDiscoveryNexusMentat(refused);
assert.equal(mentat.after.turn, 3);
assert.equal(mentat.after.nexusCards!.phase?.stage, 'complete');
assert.equal(mentat.after.discoveryEntry, undefined, 'An exhausted one-time entry cannot reopen without a newly revealed location.');
custody(mentat.after);
}
});
void test('fresh authenticated Game and seat IDs survive original setup, private cards and first free entry without mutating the caller', () => {
const fixture = createClassicDiscoveryNexusFixture({ gameId: 'AUTHENTICATEDNEXUS', seatIds: ['auth-guild', 'auth-emperor', 'auth-fremen'], advanced: true });
for (const initial of [fixture.initial, fixture.setup]) {
const before = structuredClone(initial);
const continued = createClassicDiscoveryNexusFixture({ initial });
assert.deepEqual(initial, before);
assert.equal(continued.game.code, initial.code);
assert.deepEqual(continued.game.players.map(player => player.id), initial.players.map(player => player.id));
assert.equal(continued.collector, 'auth-guild');
assert.equal(continued.opponent, 'auth-emperor');
assert.equal(continued.game.decision?.kind, 'discoveryEntry');
custody(continued.game);
}
rejectUnchanged(fixture.initial, fixture.initial.host, { type: 'start' });
assert.throws(() => createClassicDiscoveryNexusFixture({ initial: fixture.game }));
});
void test('Great Maker ordinary worm prefix precedes every vote; strict majority waits for all owners, and a tie adds no Nexus', () => {
const fixture = createClassicDiscoveryNexusFixture({ seats: 4 });
const original = openClassicDiscoveryNexusEncounter(fixture);
assert.equal(original.greatMaker?.territory, 'hagga_basin');
assert.equal(original.greatMaker?.stage, 'vote');
assert.equal(classicDiscoveryNexusPlayer(original, fixture.opponent).tanks, 3);
assert.equal(original.nexus, false);
assert.ok(nexusCleanPlayBlocked(original));
rejectUnchanged(original, original.order[1], { type: 'decision', event: original.greatMaker!.event, yes: true });
let majority = voteClassicDiscoveryNexus(reload(original), [true, true, true]);
assert.equal(majority.nexus, false);
assert.equal(majority.decision?.player, majority.order[3]);
majority = voteClassicDiscoveryNexus(majority, [false]);
assert.equal(majority.nexus, true);
assert.equal(majority.nexusCards!.phase!.occurred, true);
assert.equal(majority.decision?.kind, 'greatMakerRide');
const tied = voteClassicDiscoveryNexus(reload(original), [true, false, true, false]);
assert.equal(tied.nexus, false);
assert.equal(tied.nexusCards!.phase!.occurred, false);
const afterTie = finishClassicDiscoveryNexusMentat(tied).after;
assert.equal(afterTie.turn, 3);
assert.equal(afterTie.nexusCards!.phase!.eligible.length, 0);
custody(afterTie);
});
void test('a preceding original Shai-Hulud and tied Great Maker produce one negotiated Nexus and one closing deal, not duplicated rides or payments', () => {
const fixture = createClassicDiscoveryNexusFixture({ advanced: true, seats: 4, ordinaryWormFirst: true });
let game = openClassicDiscoveryNexusEncounter(fixture);
assert.equal(game.nexus, true);
assert.equal(classicDiscoveryNexusPlayer(game, fixture.opponent).tanks, 3);
const wallets = game.players.map(player => player.spice);
game = voteClassicDiscoveryNexus(game, [true, false, true, false]);
assert.equal(game.nexus, true);
game = openClassicDiscoveryNexusAlliance(game);
game = formClassicDiscoveryNexusAlliance(game, fixture.opponent, fixture.fremen!);
game = advanceClassicDiscoveryNexus(game, state => state.nexusCards!.phase?.stage === 'drawing');
const action: Action = { type: 'nexusCardChoice', turn: game.turn, card: null, choice: 'draw', ownRedraws: 0 };
game = applyClassicDiscoveryNexusStep(game, { actor: fixture.collector, action });
rejectUnchanged(game, fixture.collector, action);
game = advanceClassicDiscoveryNexus(game, state => state.phase === 2 && classicDiscoveryNexusClean(state));
assert.equal(game.phase, 2);
assert.deepEqual(game.players.map(player => player.spice), wallets);
assert.equal(classicDiscoveryNexusPlayer(game, fixture.opponent).tanks, 3);
assert.deepEqual(game.wormRides, []);
rejectUnchanged(game, fixture.collector, action);
custody(game);
});
void test('Fremen Great Maker reserve ride transfers real normal/starred counters into a separately revealed nested destination without spending shipment, movement or spice', () => {
for (const advanced of [false, true]) {
const fixture = createClassicDiscoveryNexusFixture({ advanced });
let game = openClassicDiscoveryNexusEncounter(fixture, false);
game = voteClassicDiscoveryNexus(game, game.players.map(() => false));
assert.equal(game.decision?.kind, 'greatMakerRide');
const fremen = classicDiscoveryNexusPlayer(game, fixture.fremen!);
const elite = advanced ? 1 : 0;
assert.ok((fremen.elites?.reserves ?? 0) >= elite);
const ride = greatMakerRideAction(viewGame(game, fremen.id), 'cistern', 0, 2, elite);
assert.ok(ride, 'A revealed separate location is a real Great Maker reserve destination.');
const event = game.greatMaker!.event;
game = applyClassicDiscoveryNexusStep(game, { actor: fremen.id, action: ride });
const after = classicDiscoveryNexusPlayer(game, fremen.id);
assert.equal(after.reserves, fremen.reserves - 2);
assert.equal(after.forces['cistern:0'], 2);
assert.equal(after.elites?.forces['cistern:0'] ?? 0, elite);
assert.equal(after.elites?.reserves ?? 0, (fremen.elites?.reserves ?? 0) - elite);
assert.equal(after.spice, fremen.spice);
assert.equal(after.shipped, fremen.shipped);
assert.equal(after.moved, fremen.moved);
assert.deepEqual(Object.fromEntries(Object.entries(after.forces).filter(([key]) => key !== 'cistern:0')), fremen.forces);
assert.equal(game.greatMaker?.stage, 'complete');
assert.deepEqual(game.wormRides, []);
assert.notEqual(game.response?.kind, 'wormSurvival');
rejectUnchanged(game, fremen.id, { ...ride, event });
const moved = advanceClassicDiscoveryNexus(game, state => state.phase === 5 && state.active === fremen.id && classicDiscoveryNexusClean(state));
assert.equal(classicDiscoveryNexusPlayer(moved, fremen.id).shipped, false);
custody(moved);
}
});
void test('actual closing Nexus draw, end-Mentat settlement and next free-entry owner are sequential; held no-phase Nexus cannot interrupt that owner', () => {
for (const advanced of [false, true]) {
const fixture = createClassicDiscoveryNexusFixture({ advanced, strongholds: advanced });
let game = secondCollection(fixture, closingDraw(fixture));
const mentat = finishClassicDiscoveryNexusMentat(game);
game = mentat.after;
assert.equal(game.turn, 3);
assert.equal(game.decision?.kind, 'discoveryEntry');
assert.equal(game.nexusCards!.phase!.stage, 'complete');
assert.equal(game.nexusCards!.cards!.hands[fixture.collector], 'ecaz');
if (advanced) assert.equal(game.strongholdCards!.claimedTurn, 2);
const blocked = quoteEcazInquiry(game, fixture.collector); assert.ok(blocked?.blocked);
rejectUnchanged(game, fixture.collector, { type: 'nexusEcazInquiry', event: blocked.event, target: fixture.opponent });
const before = classicDiscoveryNexusPlayer(game, fixture.collector);
game = enterClassicDiscoveryNexus(game, fixture.collector);
assert.equal(classicDiscoveryNexusPlayer(game, fixture.collector).forces['shrine:0'], 1);
assert.equal(classicDiscoveryNexusPlayer(game, fixture.collector).spice, before.spice);
assert.equal(game.discoveryEntry, undefined);
const offer = quoteEcazInquiry(game, fixture.collector); assert.ok(offer && !offer.blocked);
game = applyClassicDiscoveryNexusStep(game, { actor: fixture.collector,
action: { type: 'nexusEcazInquiry', event: offer.event, target: fixture.opponent } });
assert.equal(game.nexusCards!.cards!.hands[fixture.collector], null);
assert.equal(game.nexusEcazInquiries!.length, 1);
const storm = advanceClassicDiscoveryNexus(game, state => state.phase === 1 && classicDiscoveryNexusClean(state));
assert.equal(classicDiscoveryNexusPlayer(storm, fixture.collector).forces['shrine:0'], 1);
custody(storm);
}
});
void test('additional original Advanced worm destroys the real parent army but not its nested Cistern, while owned placement blocks no-phase interruption', () => {
const fixture = createClassicDiscoveryNexusFixture({ advanced: true });
let game = secondCollection(fixture, closingDraw(fixture));
const front: Game['spiceDeck'] = [];
for (let index = 0; index < 2; index++) {
const worm = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker);
assert.ok(worm >= 0); front.push(game.spiceDeck.splice(worm, 1)[0]);
}
for (let index = 0; index < 2; index++) {
const land = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery);
assert.ok(land >= 0); front.push(game.spiceDeck.splice(land, 1)[0]);
}
// Explicit turn-three unplayed positions: two original worms, two original
// land replacements. Native Fremen placement chooses the actual parent.
game.spiceDeck.unshift(...front);
game = finishClassicDiscoveryNexusMentat(game).after;
game = enterClassicDiscoveryNexus(game, fixture.collector);
game = advanceClassicDiscoveryNexus(game, state => state.decision?.kind === 'wormPlacement');
assert.ok(nexusCleanPlayBlocked(game));
const blocked = quoteEcazInquiry(game, fixture.collector); assert.ok(blocked?.blocked);
rejectUnchanged(game, fixture.collector, { type: 'nexusEcazInquiry', event: blocked.event, target: fixture.opponent });
const before = classicDiscoveryNexusPlayer(game, fixture.collector);
assert.equal(before.forces[`${fixture.parent}:${fixture.parentSector}`], 2);
assert.equal(before.forces['cistern:0'], 1);
game = applyClassicDiscoveryNexusStep(game, { actor: fixture.fremen!,
action: { type: 'decision', accept: true, territory: fixture.parent } });
game = advanceClassicDiscoveryNexus(game, state => !state.response && !state.decision);
const after = classicDiscoveryNexusPlayer(game, fixture.collector);
assert.equal(after.forces[`${fixture.parent}:${fixture.parentSector}`], undefined);
assert.equal(after.tanks, before.tanks + 2);
assert.equal(after.forces['cistern:0'], 1);
assert.equal(after.forces['shrine:0'], 1);
custody(game);
});
void test('joining a later original alliance returns the real held Nexus card and removes draw eligibility before original closing controls continue', () => {
const fixture = createClassicDiscoveryNexusFixture();
let game = secondCollection(fixture, closingDraw(fixture));
const worm = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker); assert.ok(worm >= 0);
const original = game.spiceDeck.splice(worm, 1)[0];
const land = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(land >= 0);
game.spiceDeck.unshift(original, game.spiceDeck.splice(land, 1)[0]);
game = finishClassicDiscoveryNexusMentat(game).after;
game = enterClassicDiscoveryNexus(game, fixture.collector);
game = openClassicDiscoveryNexusAlliance(game);
game = applyClassicDiscoveryNexusStep(game, { actor: fixture.opponent, action: { type: 'alliance', target: null } });
game = formClassicDiscoveryNexusAlliance(game, fixture.collector, fixture.opponent);
assert.equal(game.nexusCards!.cards!.hands[fixture.collector], null);
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'ecaz').length, 1);
game = advanceClassicDiscoveryNexus(game, state => state.nexusCards!.phase?.stage === 'drawing');
assert.deepEqual(game.nexusCards!.phase!.eligible, [fixture.fremen]);
assert.deepEqual(viewGame(game, fixture.collector).nexusCards!.choices, []);
rejectUnchanged(game, fixture.collector, { type: 'nexusCardChoice', turn: game.turn, card: null, choice: 'draw', ownRedraws: 0 });
const next = nextClassicDiscoveryNexusStep(game); assert.ok(next);
game = applyClassicDiscoveryNexusStep(game, next);
assert.equal(game.phase, 2);
custody(game);
});
void test('optional original Tech accrues after real next-turn charity and pays exactly once at native phase end', () => {
for (const advanced of [false, true]) {
const fixture = createClassicDiscoveryNexusFixture({ advanced, tech: true, techCharity: true });
let game = openClassicDiscoveryNexusEncounter(fixture);
game = voteClassicDiscoveryNexus(game, game.players.map(() => false));
game = advanceClassicDiscoveryNexus(game, state => state.phase === 2 && classicDiscoveryNexusClean(state));
const before = classicDiscoveryNexusPlayer(game, fixture.fremen!).spice;
game = applyClassicDiscoveryNexusStep(game, { actor: fixture.opponent, action: { type: 'charity' } });
game = advanceClassicDiscoveryNexus(game, state => classicDiscoveryNexusClean(state));
assert.equal(classicDiscoveryNexusPlayer(game, fixture.opponent).spice, 2);
assert.equal(game.techTokens!.production.spice, 1);
assert.equal(game.techTokens!.production.triggeredTurn, 2);
assert.equal(classicDiscoveryNexusPlayer(game, fixture.fremen!).spice, before);
game = advanceClassicDiscoveryNexus(game, state => state.phase === 3 && classicDiscoveryNexusClean(state));
assert.equal(game.techTokens!.production.spice, 0);
assert.equal(classicDiscoveryNexusPlayer(game, fixture.fremen!).spice, before + 1);
rejectUnchanged(game, fixture.opponent, { type: 'charity' });
const revival = advanceClassicDiscoveryNexus(game, state => state.phase === 4 && classicDiscoveryNexusClean(state));
assert.equal(classicDiscoveryNexusPlayer(revival, fixture.fremen!).spice, before + 1);
custody(revival);
}
});
void test('all four minimal policies have actual legal owned continuations at free entry, Great Maker vote/ride and original closing Nexus deal', () => {
const fixture = createClassicDiscoveryNexusFixture({ advanced: true });
const vote = openClassicDiscoveryNexusEncounter(fixture);
const ride = voteClassicDiscoveryNexus(reload(vote), vote.players.map(() => true));
let drawing = openClassicDiscoveryNexusAlliance(reload(ride));
drawing = formClassicDiscoveryNexusAlliance(drawing, fixture.opponent, fixture.fremen!);
drawing = advanceClassicDiscoveryNexus(drawing, game => game.nexusCards!.phase?.stage === 'drawing');
const cases = [
{ game: fixture.game, owner: fixture.collector, type: 'decision' },
{ game: vote, owner: vote.decision!.player, type: 'decision' },
{ game: ride, owner: ride.decision!.player, type: 'decision' },
{ game: drawing, owner: fixture.collector, type: 'nexusCardChoice' },
];
for (const difficulty of DIFFICULTIES) for (const current of cases) {
const view = viewGame(current.game, current.owner);
view.players.find(player => player.id === current.owner)!.bot = difficulty;
const before = structuredClone(view);
const action = botActions(view).find(action => action.type === current.type);
assert.ok(action, `${difficulty} must answer the actual owned ${current.game.decision?.kind ?? 'closing Nexus draw'}.`);
const after = applyClassicDiscoveryNexusStep(reload(current.game), { actor: current.owner, action });
assert.deepEqual(view, before);
if (current.game.decision?.kind === 'greatMakerVote')
assert.equal(after.greatMaker!.votes.length, current.game.greatMaker!.votes.length + 1);
else if (current.game.decision?.kind === 'greatMakerRide')
assert.equal(after.greatMaker!.stage, 'complete');
else if (current.game.decision?.kind === 'discoveryEntry')
assert.equal(after.discoveryEntry, undefined);
else assert.equal(after.nexusCards!.phase!.stage, 'complete');
custody(after);
}
});
+20 -1
View File
@@ -1,6 +1,6 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { createGame, initializeDiscoveryGameForAudit, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, RuleError, type Game } from '../game/engine'; import { createGame, initializeDiscoveryGameForAudit, initializeLeaderSkillsGameForAudit, initializeNexusGameForAudit, joinGame, newPlayer, RuleError, type Game } from '../game/engine';
import type { FactionId } from '../game/catalog'; import type { FactionId } from '../game/catalog';
import { createTechTokens } from '../game/tech-tokens'; import { createTechTokens } from '../game/tech-tokens';
import { createStrongholdCards } from '../game/stronghold-cards'; import { createStrongholdCards } from '../game/stronghold-cards';
@@ -70,3 +70,22 @@ void test('Discovery Skills cannot replace already initialized Discovery compone
const started = initializeDiscoveryGameForAudit(lobby(['guild', 'emperor', 'harkonnen'], [])); const started = initializeDiscoveryGameForAudit(lobby(['guild', 'emperor', 'harkonnen'], []));
assert.throws(() => initializeLeaderSkillsGameForAudit(started), RuleError); 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);
const two = lobby(['guild', 'emperor'], []);
two.nexusCards = { cards: null, phase: null }; two.techTokens = createTechTokens();
assert.throws(() => initializeNexusGameForAudit(two), RuleError);
const basic = lobby(['guild', 'emperor', 'fremen'], [], false);
basic.nexusCards = { cards: null, phase: null }; basic.strongholdCards = createStrongholdCards();
assert.throws(() => initializeNexusGameForAudit(basic), RuleError);
const used = lobby(['guild', 'emperor', 'fremen'], []);
used.nexusCards = { cards: null, phase: null }; used.techTokens = createTechTokens();
used.techTokens.production.owner = used.host;
assert.throws(() => initializeNexusGameForAudit(used), RuleError);
const started = initializeDiscoveryGameForAudit(lobby(['guild', 'emperor', 'fremen'], []));
started.nexusCards = { cards: null, phase: null };
assert.throws(() => initializeNexusGameForAudit(started), RuleError);
});
@@ -0,0 +1,318 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, initializeNexusGameForAudit, joinGame, newPlayer, viewGame,
type Action, type Game, type Player } from '../game/engine';
import type { FactionId } from '../game/catalog';
import type { Card } from '../game/cards';
import { validateDiscoveryState } from '../game/discoveries';
import { classicDiscoveryNexusProfile } from '../game/discovery-module-profile';
import { traitorDeck } from '../game/traitors';
import { ownedTech, type TechId } from '../game/tech-tokens';
import { nexusInventory } from './fixture-nexus-cards';
import { holdNexusSkillsModulesPaymentsCard, nextNexusSkillsModulesPaymentsNativeStep } from './fixture-nexus-skills-modules-payments';
export type DiscoveryClassicNexusEffectKind = 'emperor-revival' | 'fremen-revival' | 'guild-shipment';
export type DiscoveryClassicNexusEffectStep = { actor: string; action: Action };
export type DiscoveryClassicNexusEffectsOptions = {
/** Authenticated original lobby or turn-one uncompleted native setup; never redealt. */
initial?: Game;
seatIds?: string[];
advanced?: boolean;
tech?: boolean;
strongholds?: boolean;
kind?: DiscoveryClassicNexusEffectKind;
ownerFaction?: FactionId;
tanks?: number;
eliteTanks?: number;
};
export type DiscoveryClassicNexusEffectsFixture = {
initial: Game;
afterSetup: Game;
beforeReveal: Game;
afterReveal: Game;
beforeEntry: Game;
afterEntry: Game;
beforeClosingDraw: Game;
closingDraw: DiscoveryClassicNexusEffectStep;
afterClosingDraw: Game;
game: Game;
actor: string;
opponent: string;
kind: DiscoveryClassicNexusEffectKind;
paymentAction: Action;
token: string;
source: string;
tokenOwner: string | null;
tokenCount: number;
industry: TechId;
actions: DiscoveryClassicNexusEffectStep[];
staging: string[];
};
export type DiscoveryClassicNexusNestedBattle = {
beforeBattle: Game;
game: Game;
actor: string;
opponent: string;
weapon: Card;
losingLeader: string;
plans: DiscoveryClassicNexusEffectStep[];
};
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision && !game.truthtrance;
export function discoveryClassicNexusEffectsPlayer(game: Game, id: string): Player {
const player = game.players.find(player => player.id === id);
assert.ok(player, `Missing original authenticated seat ${id}`);
return player;
}
/** Labelled scalar lottery: native UUID byte-array generation is not replaced. */
function originalLottery<T>(run: () => T, scalar = 0xffffffff): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const native = crypto.getRandomValues.bind(crypto);
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1) array[0] = scalar;
else Reflect.apply(native, crypto, [array]);
return array;
};
try { return run(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
export function nextDiscoveryClassicNexusEffectsStep(game: Game): DiscoveryClassicNexusEffectStep | null {
if (!game.phaseOpening && !game.response) {
const decision = game.decision;
if (decision?.kind === 'discoveryEntry' || decision?.kind === 'greatMakerRide' || decision?.kind === 'nexusFremenCunningOffer' || decision?.kind === 'nexusFremenCunningRide')
return { actor: decision.player, action: { type: 'decision', event: decision.event, accept: false } };
if (decision?.kind === 'greatMakerVote')
return { actor: decision.player, action: { type: 'decision', event: decision.event, yes: false } };
if (decision?.kind === 'wormRide') return { actor: decision.player, action: { type: 'decision', accept: false } };
if (decision?.kind === 'wormProtection') return { actor: decision.player, action: { type: 'decision', accept: true } };
}
return nextNexusSkillsModulesPaymentsNativeStep(game);
}
export function advanceDiscoveryClassicNexusEffectsStep(game: Game, actions?: DiscoveryClassicNexusEffectStep[]): Game {
const next = nextDiscoveryClassicNexusEffectsStep(game);
assert.ok(next, 'Original continuation stopped for a human private battle plan');
actions?.push(structuredClone(next));
return applyAction(game, next.actor, next.action);
}
export function advanceDiscoveryClassicNexusEffects(state: Game, predicate: (game: Game) => boolean,
actions?: DiscoveryClassicNexusEffectStep[]): Game {
let game = state;
for (let n = 0; n < 1800; n++) {
if (predicate(game)) return game;
assert.notEqual(game.status, 'finished');
game = advanceDiscoveryClassicNexusEffectsStep(game, actions);
}
throw Error('Original Discovery/Nexus continuation did not reach its boundary');
}
export function advanceDiscoveryClassicNexusEffectsToPhase(state: Game, phase: number,
actions?: DiscoveryClassicNexusEffectStep[]): Game {
const turn = state.turn;
return advanceDiscoveryClassicNexusEffects(state, game => {
assert.equal(game.turn, turn);
return game.phase >= phase && clean(game);
}, actions);
}
export function discoveryClassicNexusEffectsInventory(game: Game): void {
nexusInventory(game);
validateDiscoveryState(game.discoveries!);
assert.deepEqual([...(game.traitorReserve ?? []), ...game.players.flatMap(player =>
[...player.traitors, ...player.traitorChoices])].sort(), traitorDeck(game.players, false).sort());
for (const player of game.players) if (player.elites) {
assert.equal(player.elites.reserves + player.elites.tanks + Object.values(player.elites.forces).reduce((a, b) => a + b, 0),
player.faction === 'fremen' ? 3 : 5);
assert.ok(player.elites.reserves <= player.reserves && player.elites.tanks <= player.tanks);
for (const [key, amount] of Object.entries(player.elites.forces)) assert.ok(amount <= (player.forces[key] ?? 0));
}
}
export function initializeDiscoveryClassicNexusEffectsSetup(options: DiscoveryClassicNexusEffectsOptions = {}): Game {
const kind = options.kind ?? 'emperor-revival', faction = options.ownerFaction ?? 'atreides';
const forbidden = kind === 'guild-shipment' ? 'guild' : kind === 'fremen-revival' ? 'fremen' : 'emperor';
let game = options.initial ? structuredClone(options.initial)
: createGame('DISCOVERYCLASSICNEXUSEFFECTS', newPlayer(options.seatIds?.[0] ?? 'owner', faction, faction),
options.advanced ?? !!options.strongholds);
if (!options.initial) {
const count = options.seatIds?.length ?? 3;
assert.ok(count >= 3 && count <= 5);
for (const candidate of ['harkonnen', 'guild', 'fremen', 'atreides', 'beneGesserit', 'emperor'] as const)
if (game.players.length < count && candidate !== forbidden && candidate !== faction)
joinGame(game, newPlayer(options.seatIds?.[game.players.length] ?? candidate, candidate, candidate));
assert.equal(game.players.length, count);
}
assert.ok(game.players.some(player => player.faction === faction));
assert.ok(!game.players.some(player => player.faction === forbidden));
if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced);
const tech = options.tech ?? !!game.techTokens, strongholds = options.strongholds ?? !!game.strongholdCards;
if (game.status === 'setup') {
assert.equal(game.turn, 1); assert.equal(game.phase, 0);
assert.equal(!!game.techTokens, tech); assert.equal(!!game.strongholdCards, strongholds);
assert.ok(classicDiscoveryNexusProfile(game));
discoveryClassicNexusEffectsInventory(game);
return game;
}
assert.equal(game.status, 'lobby');
if (tech && !game.techTokens) game = applyAction(game, game.host, { type: 'techTokens', enabled: true });
if (strongholds && !game.strongholdCards) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: true });
assert.equal(!!game.techTokens, tech); assert.equal(!!game.strongholdCards, strongholds);
game.discoveryEnabled = true;
game.nexusCards ??= { cards: null, phase: null };
assert.ok(classicDiscoveryNexusProfile(game));
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
return originalLottery(() => initializeNexusGameForAudit(game));
}
function reserveBoard(game: Game, staging: string[]): void {
for (const player of game.players) {
player.reserves += Object.values(player.forces).reduce((a, b) => a + b, 0); player.forces = {};
if (player.elites) {
player.elites.reserves += Object.values(player.elites.forces).reduce((a, b) => a + b, 0); player.elites.forces = {};
}
if (player.advisors) player.advisors = {};
}
staging.push('Conserved all original on-board counters into their own reserves; no clock, wallet, receipt or module ownership assigned.');
}
function placeOrdinary(game: Game, actor: string, key: string, amount: number, staging: string[]): void {
const player = discoveryClassicNexusEffectsPlayer(game, actor);
assert.ok(player.reserves - (player.elites?.reserves ?? 0) >= amount);
player.reserves -= amount; player.forces[key] = (player.forces[key] ?? 0) + amount;
staging.push(`Conserved ${amount} original ordinary ${actor} reserve counters to ${key}.`);
}
function orderSpice(game: Game, selectors: ('discovery' | 'land' | 'worm')[], staging: string[]): void {
const front: Game['spiceDeck'] = [];
for (const selector of selectors) {
const index = game.spiceDeck.findIndex(card => selector === 'worm' ? 'worm' in card
: 'territory' in card && (selector === 'discovery' ? card.discovery === 'discovery-hagga-basin'
: !card.discovery && card.territory !== 'gara_kulon'));
assert.ok(index >= 0, 'Only original undealt physical Spice singletons may be selected');
front.push(game.spiceDeck.splice(index, 1)[0]);
}
game.spiceDeck.unshift(...front);
staging.push(`Ordered conserved original undealt Spice Cards: ${selectors.join(', ')}; original discard untouched.`);
}
/** Original setup, actual printed Discovery blow/lottery, Collection reveal,
* next-turn entry and end-Spice-Blow Nexus/alliance draw all precede the effect. */
export function createDiscoveryClassicNexusEffectsFixture(options: DiscoveryClassicNexusEffectsOptions = {}): DiscoveryClassicNexusEffectsFixture {
const initial = initializeDiscoveryClassicNexusEffectsSetup(options);
return originalLottery(() => {
const kind = options.kind ?? 'emperor-revival', actor = initial.players.find(player => player.faction === (options.ownerFaction ?? 'atreides'))!.id;
const opponent = initial.players.find(player => player.id !== actor && player.faction !== 'beneGesserit')!.id;
const actions: DiscoveryClassicNexusEffectStep[] = [], staging = ['Scalar lottery fixed at the last legal index; UUID bytes remain native. Physical Hiereg lottery selects the original Shrine token; not an untouched random sample.'];
let game = advanceDiscoveryClassicNexusEffects(initial, game => game.status === 'playing', actions);
const afterSetup = structuredClone(game);
reserveBoard(game, staging);
// The actual first Storm producers assign original Tech owners.
game = advanceDiscoveryClassicNexusEffectsToPhase(game, 1, actions);
orderSpice(game, ['discovery', 'land'], staging);
const pool = game.discoveries!.tokens.filter(token => token.type === 'hiereg' && token.status === 'supply');
const lotteryIndex = pool.findIndex(token => token.face === 'shrine'); assert.ok(lotteryIndex >= 0);
staging.push(`Actual printed Discovery blow lottery selects index ${lotteryIndex} of ${pool.length} original supply Hiereg tokens; original token IDs/order remain unchanged.`);
game = originalLottery(() => advanceDiscoveryClassicNexusEffects(game,
state => state.discoveries!.tokens.some(token => token.face === 'shrine' && token.status === 'placed'), actions),
Math.floor((lotteryIndex + 0.5) * 0x100000000 / pool.length));
game = advanceDiscoveryClassicNexusEffectsToPhase(game, 5, actions);
const shrine = game.discoveries!.tokens.find(token => token.face === 'shrine')!;
assert.equal(shrine.status, 'placed');
assert.equal(shrine.revealedTurn, null);
const source = `${shrine.territory}:${shrine.sector}`, revealer = kind === 'guild-shipment' ? opponent : actor;
assert.notEqual(shrine.sector, game.storm);
placeOrdinary(game, revealer, source, 2, staging);
if (game.strongholdCards) placeOrdinary(game, actor, 'arrakeen:10', 1, staging);
game = advanceDiscoveryClassicNexusEffectsToPhase(game, 7, actions);
if (viewGame(game, revealer).discoveries!.canInspect.includes(shrine.id)) {
const action: Action = { type: 'discovery', token: shrine.id, reveal: false };
actions.push({ actor: revealer, action }); game = applyAction(game, revealer, action);
}
const beforeReveal = structuredClone(game), reveal: Action = { type: 'discovery', token: shrine.id, reveal: true };
assert.ok(viewGame(game, revealer).discoveries!.canReveal.includes(shrine.id));
actions.push({ actor: revealer, action: reveal }); game = applyAction(game, revealer, reveal);
const afterReveal = structuredClone(game);
game = advanceDiscoveryClassicNexusEffects(game, game => game.decision?.kind === 'discoveryEntry', actions);
assert.ok(game.decision?.kind === 'discoveryEntry');
assert.equal(game.turn, 2); assert.equal(game.decision!.player, revealer);
const beforeEntry = structuredClone(game), entry: Action = { type: 'decision', event: game.decision!.event,
accept: true, groups: viewGame(game, revealer).discoveryEntry!.sources };
actions.push({ actor: revealer, action: entry }); game = applyAction(game, revealer, entry);
const afterEntry = structuredClone(game);
assert.equal(discoveryClassicNexusEffectsPlayer(game, revealer).forces['shrine:0'], 2);
game = advanceDiscoveryClassicNexusEffectsToPhase(game, 1, actions);
orderSpice(game, ['worm', 'land', 'land'], staging);
let allied = false;
for (let n = 0; game.nexusCards?.phase?.stage !== 'drawing' && n < 240; n++) {
if (game.nexus && !game.spiceWindow && !game.spiceResolution && clean(game) && !allied) {
const others = game.players.filter(player => player.id !== actor);
for (const [from, to] of [[others[0].id, others[1].id], [others[1].id, others[0].id]]) {
const action: Action = { type: 'alliance', target: to };
actions.push({ actor: from, action }); game = applyAction(game, from, action);
}
allied = true;
}
game = advanceDiscoveryClassicNexusEffectsStep(game, actions);
}
assert.ok(allied); assert.equal(game.nexusCards?.phase?.stage, 'drawing');
const selected = kind === 'guild-shipment' ? 'guild' : kind === 'fremen-revival' ? 'fremen' : 'emperor';
const cards = game.nexusCards!.cards!, index = cards.deck.indexOf(selected); assert.ok(index >= 0);
cards.deck.unshift(cards.deck.splice(index, 1)[0]);
staging.push(`Ordered the original undealt ${selected} Nexus singleton before its actual end-Spice-Blow draw.`);
const beforeClosingDraw = structuredClone(game), closingDraw: DiscoveryClassicNexusEffectStep = { actor,
action: { type: 'nexusCardChoice', turn: game.turn, card: cards.hands[actor], choice: 'draw', ownRedraws: 0 } };
actions.push(closingDraw); game = applyAction(game, actor, closingDraw.action);
const afterClosingDraw = structuredClone(game);
assert.equal(game.nexusCards!.cards!.hands[actor], selected);
game = advanceDiscoveryClassicNexusEffects(game, game => game.nexusCards?.phase?.stage !== 'drawing', actions);
game = advanceDiscoveryClassicNexusEffectsToPhase(game, kind === 'guild-shipment' ? 5 : 4, actions);
let paymentAction: Action;
if (kind === 'guild-shipment') {
game = advanceDiscoveryClassicNexusEffects(game, game => game.active === actor && clean(game), actions);
paymentAction = { type: 'ship', territory: 'shrine', sector: 0, amount: 3, elite: 0,
nexus: viewGame(game, actor).nexusGuildSecretAlly!.event };
} else {
const player = discoveryClassicNexusEffectsPlayer(game, actor), tanks = options.tanks ?? 6, elite = options.eliteTanks ?? 0;
assert.ok(Number.isInteger(tanks) && tanks >= 0 && Number.isInteger(elite) && elite >= 0 && elite <= tanks);
assert.ok(tanks - elite <= player.reserves - (player.elites?.reserves ?? 0) && elite <= (player.elites?.reserves ?? 0));
player.reserves -= tanks; player.tanks += tanks;
if (player.elites) { player.elites.reserves -= elite; player.elites.tanks += elite; }
staging.push(`Conserved ${tanks} original ${actor} reserve counters to Tanks, including ${elite} elites; no return or quota receipt assigned.`);
const view = viewGame(game, actor);
paymentAction = { type: kind === 'emperor-revival' ? 'nexusEmperorRevive' : 'nexusFremenRevive',
event: kind === 'emperor-revival' ? view.nexusEmperorSecretAlly!.event : view.nexusFremenRevival!.event, elite: elite ? 1 : 0 };
}
discoveryClassicNexusEffectsInventory(game);
const industry: TechId = kind === 'guild-shipment' ? 'heighliners' : 'axlotl', tokenOwner = game.techTokens?.[industry].owner ?? null;
return { initial, afterSetup, beforeReveal, afterReveal, beforeEntry, afterEntry, beforeClosingDraw, closingDraw,
afterClosingDraw, game, actor, opponent, kind, paymentAction, token: shrine.id, source, industry, tokenOwner,
tokenCount: tokenOwner ? ownedTech(game.techTokens, tokenOwner).length : 0, actions, staging };
});
}
export function payDiscoveryClassicNexusEffectsFixture(fixture: DiscoveryClassicNexusEffectsFixture, game = fixture.game): Game {
return applyAction(game, fixture.actor, fixture.paymentAction);
}
export function finishDiscoveryClassicNexusEffectsInteraction(state: Game, actions?: DiscoveryClassicNexusEffectStep[]): Game {
return advanceDiscoveryClassicNexusEffects(state, game => clean(game) && !game.pendingTreacheryDiscard && !game.pendingKarama, actions);
}
/** Paid nested arrival is retained, then the ordinary battle producer seals real
* physical cards/plans. No battle result, ownership or reward is fabricated. */
export function openDiscoveryClassicNexusNestedBattle(fixture: DiscoveryClassicNexusEffectsFixture, state: Game): DiscoveryClassicNexusNestedBattle {
assert.equal(fixture.kind, 'guild-shipment');
let game = finishDiscoveryClassicNexusEffectsInteraction(state, fixture.actions);
for (const player of game.players) { game.deck.push(...player.hand); player.hand = []; }
fixture.staging.push('Conserved all original held Treachery Cards into the undealt deck before selecting the nested battle cards.');
const weapon = holdNexusSkillsModulesPaymentsCard(game, fixture.actor, 'projectile', fixture.staging);
const owner = discoveryClassicNexusEffectsPlayer(game, fixture.actor), opponent = discoveryClassicNexusEffectsPlayer(game, fixture.opponent);
const leader = owner.leaders.filter(leader => !leader.dead && !leader.usedAt).sort((a, b) => b.strength - a.strength)[0];
const losingLeader = opponent.leaders.filter(leader => !leader.dead && !leader.usedAt).sort((a, b) => a.strength - b.strength)[0];
assert.ok(leader && losingLeader);
game = advanceDiscoveryClassicNexusEffectsToPhase(game, 6, fixture.actions);
assert.ok(game.active === owner.id || game.active === opponent.id);
const beforeBattle = structuredClone(game), choose: Action = { type: 'chooseBattle', territory: 'shrine', target: game.active === owner.id ? opponent.id : owner.id };
fixture.actions.push({ actor: game.active!, action: choose }); game = applyAction(game, game.active!, choose);
game = advanceDiscoveryClassicNexusEffects(game, game => !!game.battle && clean(game) && !game.battle.preparation && game.battle.preLeader?.closed !== false, fixture.actions);
const plans: DiscoveryClassicNexusEffectStep[] = [
{ actor: owner.id, action: { type: 'battlePlan', leader: leader.id, dial: 0, support: 0, weapon: weapon.id } },
{ actor: opponent.id, action: { type: 'battlePlan', leader: losingLeader.id, dial: 0, support: 0 } },
];
return { beforeBattle, game, actor: owner.id, opponent: opponent.id, weapon, losingLeader: losingLeader.id, plans };
}
export function finishDiscoveryClassicNexusNestedBattle(battle: DiscoveryClassicNexusNestedBattle): Game {
let game = battle.game;
for (const step of battle.plans) game = applyAction(game, step.actor, step.action);
return advanceDiscoveryClassicNexusEffects(game, game => !game.battle && clean(game) && !game.pendingTreacheryDiscard);
}
+269
View File
@@ -0,0 +1,269 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeNexusGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game, type Player,
} from '../game/engine';
import type { FactionId } from '../game/catalog';
import { DISCOVERY_CARD_PLACEMENTS, DISCOVERY_TOKEN_BY_ID, type DiscoveryTokenFace } from '../game/discoveries';
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
import { classicDiscoveryNexusProfile } from '../game/discovery-module-profile';
import { nextNexusSkillsModulesPaymentsNativeStep } from './fixture-nexus-skills-modules-payments';
export type ClassicDiscoveryNexusStep = { actor: string; action: Action };
export type ClassicDiscoveryNexusOptions = {
/** Authenticated fresh lobby or its undealt original staged setup; never a played save. */
initial?: Game;
gameId?: string;
seatIds?: string[];
seats?: 2 | 3 | 4 | 5 | 6;
advanced?: boolean;
tech?: boolean;
strongholds?: boolean;
/** Actual ordinary worm immediately before the Great Maker, not a Nexus flag assignment. */
ordinaryWormFirst?: boolean;
/** Actual ten-spice Emperor shipment to an empty desert makes next-turn charity relevant. */
techCharity?: boolean;
};
export type ClassicDiscoveryNexusMentat = {
before: Game; step: ClassicDiscoveryNexusStep; after: Game;
};
export type ClassicDiscoveryNexusFixture = {
initial: Game; setup: Game; collection: Game; game: Game;
firstMentat: ClassicDiscoveryNexusMentat;
collector: string; opponent: string; fremen: string | null;
token: string; parent: string; parentSector: number; wormSource: string;
actions: ClassicDiscoveryNexusStep[]; staging: string[];
};
export const classicDiscoveryNexusClean = (game: Game): boolean =>
!game.phaseOpening && !game.response && !game.decision && !game.truthtrance && !game.pendingTreacheryDiscard;
export function classicDiscoveryNexusPlayer(game: Game, id: string): Player {
const player = game.players.find(candidate => candidate.id === id);
assert.ok(player, `Missing original classic seat ${id}`);
return player;
}
/** Explicit scalar lottery position: preserve the original shuffled arrays.
* This also selects original Storm Card 1 first, rather than assigning a clock
* or storm distance. UUID/byte-array entropy remains native. */
export function withClassicDiscoveryNexusLottery<T>(operation: () => T): T {
const native = crypto.getRandomValues.bind(crypto);
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1) array[0] = 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');
}
}
/** No optional spending, shipment, movement, card play, alliance or sealed
* Battle Plan is invented by this driver. Every step belongs to a live owner.
* Shared original controls retain priority over the optional Discovery choices. */
export function nextClassicDiscoveryNexusStep(game: Game): ClassicDiscoveryNexusStep | null {
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) {
const decision = game.decision;
if (decision?.kind === 'discoveryEntry' || decision?.kind === 'greatMakerRide')
return { actor: decision.player, action: { type: 'decision', event: decision.event, accept: false } };
if (decision?.kind === 'greatMakerVote')
return { actor: decision.player, action: { type: 'decision', event: decision.event, yes: false } };
if (decision?.kind === 'wormRide' || decision?.kind === 'wormPlacement')
return { actor: decision.player, action: { type: 'decision', accept: false } };
if (decision?.kind === 'wormProtection')
return { actor: decision.player, action: { type: 'decision', accept: true } };
if (decision?.kind === 'advisor' || decision?.kind === 'intrusion')
return { actor: decision.player, action: { type: 'decision', accept: false } };
if (decision?.kind === 'discoveryDiscard') {
const card = classicDiscoveryNexusPlayer(game, decision.player).hand[0];
assert.ok(card);
return { actor: decision.player, action: { type: 'decision', event: decision.event, card: card.id } };
}
}
return nextNexusSkillsModulesPaymentsNativeStep(game);
}
export function applyClassicDiscoveryNexusStep(game: Game, step: ClassicDiscoveryNexusStep,
actions?: ClassicDiscoveryNexusStep[]): Game {
actions?.push(structuredClone(step));
return withClassicDiscoveryNexusLottery(() => applyAction(game, step.actor, step.action));
}
export function advanceClassicDiscoveryNexus(game: Game, until: (game: Game) => boolean,
actions?: ClassicDiscoveryNexusStep[]): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original lifecycle ended before its requested continuation.');
const step = nextClassicDiscoveryNexusStep(game);
assert.ok(step, 'The original continuation needs a human sealed Battle Plan.');
game = applyClassicDiscoveryNexusStep(game, step, actions);
}
throw new Error('The original Nexus/Discovery lifecycle did not reach its owned continuation.');
}
/** Physical supply-token lottery only; never writes a token location or face. */
export function withClassicDiscoveryNexusToken<T>(game: Game, face: DiscoveryTokenFace, operation: () => T): T {
const eligible = game.discoveries!.tokens.filter(token => token.status === 'supply' && token.type === DISCOVERY_TOKEN_BY_ID[face].type);
const index = eligible.findIndex(token => token.face === face);
assert.ok(index >= 0, 'The requested physical token must remain in its original supply.');
const native = crypto.getRandomValues.bind(crypto);
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1)
array[0] = Math.floor((index + 0.5) / eligible.length * 0x100000000);
else Reflect.apply(native, crypto, [array]);
return array;
};
try { return operation(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
function orderSpice(game: Game, options: ClassicDiscoveryNexusOptions): void {
const take = (matches: (card: Game['spiceDeck'][number]) => boolean) => {
const index = game.spiceDeck.findIndex(matches);
assert.ok(index >= 0, 'Each selected physical Spice Card must still be undealt.');
return game.spiceDeck.splice(index, 1)[0];
};
const front = [take(card => 'territory' in card && card.discovery === 'discovery-hagga-basin')];
if (game.advanced) front.push(take(card => 'territory' in card && !card.discovery && card.territory === 'broken_land'));
if (options.ordinaryWormFirst) front.push(take(card => 'worm' in card && !card.greatMaker));
front.push(take(card => 'worm' in card && !!card.greatMaker));
front.push(take(card => 'territory' in card && card.discovery === 'discovery-sihaya-ridge'));
if (game.advanced) front.push(take(card => 'territory' in card && !card.discovery && card.territory === 'rock_outcroppings'));
game.spiceDeck.unshift(...front);
}
export function revealClassicDiscoveryNexus(game: Game, owner: string, token: string,
actions?: ClassicDiscoveryNexusStep[]): Game {
if (viewGame(game, owner).discoveries!.canInspect.some(id => id === token))
game = applyClassicDiscoveryNexusStep(game, { actor: owner, action: { type: 'discovery', token, reveal: false } }, actions);
return applyClassicDiscoveryNexusStep(game, { actor: owner, action: { type: 'discovery', token, reveal: true } }, actions);
}
export function finishClassicDiscoveryNexusMentat(game: Game, actions?: ClassicDiscoveryNexusStep[]): ClassicDiscoveryNexusMentat {
const turn = game.turn;
game = advanceClassicDiscoveryNexus(game, state => state.phase === 8 && classicDiscoveryNexusClean(state), actions);
for (let count = 0; count < 100; count++) {
const before = structuredClone(game), step = nextClassicDiscoveryNexusStep(game);
assert.ok(step);
game = applyClassicDiscoveryNexusStep(game, step, actions);
if (game.turn !== turn || game.status === 'finished') return { before, step, after: game };
}
throw new Error('The original Mentat controls failed to close the turn.');
}
export function enterClassicDiscoveryNexus(game: Game, owner: string, amount = 1,
actions?: ClassicDiscoveryNexusStep[]): Game {
assert.ok(game.decision?.kind === 'discoveryEntry');
assert.equal(game.decision.player, owner);
const view = viewGame(game, owner), source = view.discoveryEntry!.sources.find(source => source.normal >= amount);
assert.ok(source, 'Select physically available ordinary source counters.');
const action = discoveryEntryMoveAction(view, [{ source: source.source, normal: amount, elite: 0 }]);
assert.ok(action);
return applyClassicDiscoveryNexusStep(game, { actor: owner, action }, actions);
}
/** Original base33/all12 Nexus + seven Spice Cards/eight Discovery tokens.
* All clocks, wallets, board counters and pending frames arise from native
* staged setup and actions. Only undealt cards and supply/Storm lotteries are
* positioned explicitly. The returned human window is before turn-two Storm. */
export function createClassicDiscoveryNexusFixture(options: ClassicDiscoveryNexusOptions = {}): ClassicDiscoveryNexusFixture {
let game: Game;
if (options.initial) game = structuredClone(options.initial);
else {
const factions: FactionId[] = ['guild', 'emperor', 'fremen', 'atreides', 'harkonnen', 'beneGesserit'];
const count = options.seats ?? options.seatIds?.length ?? 3;
assert.ok(count >= 2 && count <= 6);
if (options.seatIds) assert.equal(options.seatIds.length, count);
const ids = options.seatIds ?? factions;
game = createGame(options.gameId ?? 'CLASSICDISCOVERYNEXUS', newPlayer(ids[0], 'Guild', 'guild'), options.advanced ?? false);
for (let seat = 1; seat < count; seat++) joinGame(game, newPlayer(ids[seat], factions[seat], factions[seat]));
}
assert.deepEqual(game.expansions, []);
assert.ok(!game.homeworlds && !game.leaderSkills && !game.ecazTreachery && !game.spiceBankerIncomePreview);
assert.ok(game.status === 'lobby' || (game.status === 'setup' && game.turn === 1 && game.phase === 0 &&
['prediction', 'traitors'].includes(game.setupStage!) && game.players.every(player => !player.hand.length && !player.traitors.length)));
if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced);
const collector = game.players.find(player => player.faction === 'guild')?.id;
const opponent = game.players.find(player => player.faction === 'emperor')?.id;
assert.ok(collector && opponent, 'This original human recipe needs its Guild collector and Emperor source army.');
const actions: ClassicDiscoveryNexusStep[] = [];
if (game.status === 'lobby') {
if (options.tech && !game.techTokens) game = applyAction(game, game.host, { type: 'techTokens', enabled: true });
if (options.strongholds && !game.strongholdCards) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: true });
game.discoveryEnabled = true;
game.nexusCards ??= { cards: null, phase: null };
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
}
const initial = structuredClone(game);
if (game.status === 'lobby') game = withClassicDiscoveryNexusLottery(() => initializeNexusGameForAudit(game));
assert.ok(game.discoveryEnabled && game.discoveries && game.nexusCards?.cards);
assert.ok(classicDiscoveryNexusProfile(game));
const setup = structuredClone(game);
game = advanceClassicDiscoveryNexus(game, state => state.status === 'playing', actions);
orderSpice(game, options);
game = advanceClassicDiscoveryNexus(game, state => state.phase === 1 && classicDiscoveryNexusClean(state), actions);
// Token selection must surround the real Spice Blow action, not the generic
// scalar-lottery wrapper that would otherwise own that same random draw.
for (let count = 0; !game.discoveries!.tokens.some(token => token.face === 'cistern' && token.status === 'placed') && count < 100; count++) {
const step = nextClassicDiscoveryNexusStep(game); assert.ok(step);
actions.push(structuredClone(step));
game = withClassicDiscoveryNexusToken(game, 'cistern', () => applyAction(game, step.actor, step.action));
}
assert.ok(game.discoveries!.tokens.some(token => token.face === 'cistern' && token.status === 'placed'));
const placement = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'];
const shipped = new Set<string>();
game = advanceClassicDiscoveryNexus(game, state => state.phase === 5 && classicDiscoveryNexusClean(state), actions);
for (let count = 0; game.phase === 5 && count < 200; count++) {
if (classicDiscoveryNexusClean(game) && game.active && !shipped.has(game.active) && [collector, opponent].includes(game.active)) {
const actor = game.active;
const action: Action = actor === collector
? { type: 'ship', territory: placement.territory, sector: placement.sector, amount: 3, elite: 0, allyPayment: 0 }
: { type: 'ship', territory: options.techCharity ? 'rock_outcroppings' : 'hagga_basin', sector: options.techCharity ? 13 : 12,
amount: options.techCharity ? 5 : 3, elite: 0, allyPayment: 0 };
game = applyClassicDiscoveryNexusStep(game, { actor, action }, actions);
shipped.add(actor);
} else {
const step = nextClassicDiscoveryNexusStep(game); assert.ok(step);
game = applyClassicDiscoveryNexusStep(game, step, actions);
}
}
assert.equal(shipped.size, 2);
game = advanceClassicDiscoveryNexus(game, state => state.phase === 7 && classicDiscoveryNexusClean(state), actions);
const token = game.discoveries!.tokens.find(token => token.face === 'cistern')!;
const collection = structuredClone(game);
game = revealClassicDiscoveryNexus(game, collector, token.id, actions);
const firstMentat = finishClassicDiscoveryNexusMentat(game, actions);
game = firstMentat.after;
assert.equal(game.turn, 2);
assert.ok(game.decision?.kind === 'discoveryEntry');
assert.equal(game.decision.player, collector);
return { initial, setup, collection, firstMentat, game, collector, opponent,
fremen: game.players.find(player => player.faction === 'fremen')?.id ?? null,
token: token.id, parent: placement.territory, parentSector: placement.sector, wormSource: 'hagga_basin:12', actions,
staging: ['Conserved undealt Spice positions: turn-one Hagga Basin Discovery, optional Advanced Broken Land; turn-two optional ordinary Shai-Hulud, Great Maker, Sihaya Ridge Discovery, optional Advanced Rock Outcroppings.',
'Selected original Cistern supply-token lottery; original scalar shuffle/Storm lottery positions. No phases, wallets, armies or pending frames assigned.',
options.techCharity ? 'Native Emperor five-force/ten-spice Rock Outcroppings shipment supplies actual later charity.' : 'Native Emperor three-force Hagga Basin shipment supplies actual Great Maker worm casualties.'] };
}
/** Continue into the real encounter without silently voting, riding or entering. */
export function openClassicDiscoveryNexusEncounter(fixture: ClassicDiscoveryNexusFixture, enter = true): Game {
assert.ok(fixture.game.decision?.kind === 'discoveryEntry');
const game = enter ? enterClassicDiscoveryNexus(fixture.game, fixture.collector) : applyClassicDiscoveryNexusStep(fixture.game,
{ actor: fixture.collector, action: { type: 'decision', event: fixture.game.decision!.event, accept: false } });
return advanceClassicDiscoveryNexus(game, state => state.greatMaker?.stage === 'vote');
}
export function voteClassicDiscoveryNexus(game: Game, votes: readonly boolean[], actions?: ClassicDiscoveryNexusStep[]): Game {
for (const yes of votes) {
assert.ok(game.decision?.kind === 'greatMakerVote');
game = applyClassicDiscoveryNexusStep(game, { actor: game.decision.player,
action: { type: 'decision', event: game.decision.event, yes } }, actions);
}
return game;
}
export function openClassicDiscoveryNexusAlliance(game: Game): Game {
return advanceClassicDiscoveryNexus(game, state => state.nexus && !state.spiceWindow && !state.spiceResolution && classicDiscoveryNexusClean(state));
}
export function formClassicDiscoveryNexusAlliance(game: Game, first: string, second: string): Game {
game = applyClassicDiscoveryNexusStep(game, { actor: first, action: { type: 'alliance', target: second } });
return applyClassicDiscoveryNexusStep(game, { actor: second, action: { type: 'alliance', target: first } });
}
+22 -10
View File
@@ -49,7 +49,8 @@ type Profile = 'base' | 'choam' | 'ecaz' | 'ecaz-treachery' | 'ecaz-occupy' | 'm
| 'nexus-skills-tech' | 'nexus-skills-stronghold' | 'nexus-skills-stronghold-tech' | 'nexus-skills-tech' | 'nexus-skills-stronghold' | 'nexus-skills-stronghold-tech'
| 'paired-nexus-skills-tech' | 'paired-nexus-skills-stronghold' | 'paired-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' | 'homeworld-occupation' | 'e3-nexus-skills' | 'e3-nexus-skills-tech' | 'e3-nexus-skills-stronghold' | 'e3-nexus-skills-stronghold-tech' | 'homeworld-occupation'
| 'discovery' | 'discovery-tech' | 'discovery-skills' | 'discovery-skills-tech'; | 'discovery' | 'discovery-tech' | 'discovery-skills' | 'discovery-skills-tech'
| 'discovery-nexus' | 'discovery-nexus-tech' | 'discovery-nexus-stronghold' | 'discovery-nexus-stronghold-tech';
type Rules = 'basic' | 'advanced'; type Rules = 'basic' | 'advanced';
type Scenario = { type Scenario = {
@@ -175,15 +176,18 @@ const CLASSIC_NEXUS_MODULE_SCENARIOS: readonly Scenario[] =
(profile === 'nexus-tech' || scenario.rules === 'advanced') && (profile === 'nexus-tech' || scenario.rules === 'advanced') &&
(profile === 'nexus-stronghold' || scenario.roster.length >= 3)) (profile === 'nexus-stronghold' || scenario.roster.length >= 3))
.map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 800 + band * 100 + index }))); .map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 800 + band * 100 + index })));
const discoveryNexusProfile = (profile: string) => profile === 'discovery-nexus' ||
profile === 'discovery-nexus-tech' || profile === 'discovery-nexus-stronghold' ||
profile === 'discovery-nexus-stronghold-tech';
const nexusModuleProfile = (profile: string) => const nexusModuleProfile = (profile: string) =>
profile === 'nexus-tech' || profile === 'nexus-stronghold' || profile === 'nexus-stronghold-tech' || profile === 'nexus-tech' || profile === 'nexus-stronghold' || profile === 'nexus-stronghold-tech' ||
pairedNexusModuleProfile(profile) || nexusSkillsModuleProfile(profile) || pairedNexusSkillsModuleProfile(profile) || e3NexusSkillsProfile(profile); discoveryNexusProfile(profile) || pairedNexusModuleProfile(profile) || nexusSkillsModuleProfile(profile) || pairedNexusSkillsModuleProfile(profile) || e3NexusSkillsProfile(profile);
const nexusTechProfile = (profile: string) => profile === 'nexus-tech' || profile === 'nexus-stronghold-tech' || const nexusTechProfile = (profile: string) => 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' || profile === 'paired-nexus-tech' || profile === 'paired-nexus-stronghold-tech' ||
profile === 'nexus-skills-tech' || profile === 'nexus-skills-stronghold-tech' || profile === 'nexus-skills-tech' || profile === 'nexus-skills-stronghold-tech' ||
profile === 'paired-nexus-skills-tech' || profile === 'paired-nexus-skills-stronghold-tech' || profile === 'paired-nexus-skills-tech' || profile === 'paired-nexus-skills-stronghold-tech' ||
profile === 'e3-nexus-skills-tech' || profile === 'e3-nexus-skills-stronghold-tech'; profile === 'e3-nexus-skills-tech' || profile === 'e3-nexus-skills-stronghold-tech';
const nexusStrongholdProfile = (profile: string) => profile === 'nexus-stronghold' || profile === 'nexus-stronghold-tech' || const nexusStrongholdProfile = (profile: string) => 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' || profile === 'paired-nexus-stronghold' || profile === 'paired-nexus-stronghold-tech' ||
profile === 'nexus-skills-stronghold' || profile === 'nexus-skills-stronghold-tech' || profile === 'nexus-skills-stronghold' || profile === 'nexus-skills-stronghold-tech' ||
profile === 'paired-nexus-skills-stronghold' || profile === 'paired-nexus-skills-stronghold-tech' || profile === 'paired-nexus-skills-stronghold' || profile === 'paired-nexus-skills-stronghold-tech' ||
@@ -398,6 +402,12 @@ const DISCOVERY_SKILLS_SCENARIOS: readonly Scenario[] =
(['discovery-skills', 'discovery-skills-tech'] as const).flatMap((profile, band) => (['discovery-skills', 'discovery-skills-tech'] as const).flatMap((profile, band) =>
BASE_SCENARIOS.filter(scenario => profile === 'discovery-skills' || scenario.roster.length >= 3) BASE_SCENARIOS.filter(scenario => profile === 'discovery-skills' || scenario.roster.length >= 3)
.map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 3000 + band * 100 + index }))); .map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 3000 + band * 100 + index })));
const DISCOVERY_NEXUS_SCENARIOS: readonly Scenario[] =
(['discovery-nexus', 'discovery-nexus-tech', 'discovery-nexus-stronghold', 'discovery-nexus-stronghold-tech'] as const)
.flatMap((profile, band) => BASE_SCENARIOS
.filter(scenario => (!profile.includes('stronghold') || scenario.rules === 'advanced') &&
(!profile.endsWith('tech') || scenario.roster.length >= 3))
.map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 3600 + band * 100 + index })));
const STRONGHOLD_FACTIONS_ROSTER: readonly FactionId[] = [ const STRONGHOLD_FACTIONS_ROSTER: readonly FactionId[] = [
'ixians', 'choam', 'emperor', 'fremen', 'harkonnen', 'beneGesserit', 'ixians', 'choam', 'emperor', 'fremen', 'harkonnen', 'beneGesserit',
]; ];
@@ -517,7 +527,7 @@ type Result = {
function usage() { function usage() {
return ( return (
'Usage: node --import tsx tools/faction-games.ts --out NEW_PRIVATE_DIR ' + '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|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] ' + '[--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] ' +
'[--rules both|basic|advanced] [--players all|2|3|4|5|6] [--max-actions POSITIVE] ' + '[--rules both|basic|advanced] [--players all|2|3|4|5|6] [--max-actions POSITIVE] ' +
'[--resume FAILED_GAME.json]\n' + '[--resume FAILED_GAME.json]\n' +
'Runs genuine setup and gameplay offline. Default/all keeps the six expansion samples; combined-nexus and combined-homeworld-nexus add five/six-seat Basic/Advanced all-expansion samples. Base, Homeworld, Nexus, Homeworld-Nexus, expansion roster, paired expansion Nexus, Ecaz card variant, Advanced Moritani assassination and skill profiles select their documented rosters. Stronghold-factions is Advanced only: Ixians + CHOAM, native Ixians + Tleilaxu, or native CHOAM + Richese with classic opponents, two through six seats; mixed four-native rosters also run at four through six seats. Selected Ix/CHOAM decks determine the canonical 47/35 Treachery Cards; Richese has a separate ten-card cache and all samples have six separate Stronghold Cards without other modules. Scenario names identify their native roster. --players requires a supported profile. Output must be a new private directory outside the checkout.' + 'Runs genuine setup and gameplay offline. Default/all keeps the six expansion samples; combined-nexus and combined-homeworld-nexus add five/six-seat Basic/Advanced all-expansion samples. Base, Homeworld, Nexus, Homeworld-Nexus, expansion roster, paired expansion Nexus, Ecaz card variant, Advanced Moritani assassination and skill profiles select their documented rosters. Stronghold-factions is Advanced only: Ixians + CHOAM, native Ixians + Tleilaxu, or native CHOAM + Richese with classic opponents, two through six seats; mixed four-native rosters also run at four through six seats. Selected Ix/CHOAM decks determine the canonical 47/35 Treachery Cards; Richese has a separate ten-card cache and all samples have six separate Stronghold Cards without other modules. Scenario names identify their native roster. --players requires a supported profile. Output must be a new private directory outside the checkout.' +
@@ -539,6 +549,7 @@ function usage() {
+ '\nE3-nexus-skills composes standalone Ecaz OR Moritani and classics, ecaz33/all14/all12, Basic/Advanced2..6. Tech requires3+; Strongholds requires Advanced; both3+ Advanced. Original Duke/assignment and Moritani assassination/normal-traitor-forfeit guards remain, including Advanced Harkonnen exclusion. Real closing draws, first-Storm/end-Mentat custody, source skill effects and original payments/rewards remain. No E3 pair/mixed families/HW/independent cards/previews/public starts or new rulings.' + '\nE3-nexus-skills composes standalone Ecaz OR Moritani and classics, ecaz33/all14/all12, Basic/Advanced2..6. Tech requires3+; Strongholds requires Advanced; both3+ Advanced. Original Duke/assignment and Moritani assassination/normal-traitor-forfeit guards remain, including Advanced Harkonnen exclusion. Real closing draws, first-Storm/end-Mentat custody, source skill effects and original payments/rewards remain. No E3 pair/mixed families/HW/independent cards/previews/public starts or new rulings.'
+ '\nDiscovery uses fresh classic, paired E1/E2 or standalone Ecaz OR Moritani setups with original selected decks, six Discovery territory cards, Great Maker and eight physical tokens in Basic/Advanced2..6. Discovery-tech preserves unused original Tech3..6. Native setup, actual token entry, typed force paths, stash and nested location controls reuse original handlers. No Skills/HW/Nexus/Strongholds, mixed E3 families, new contested/shared-payer rulings, public starts or played-game conversion.' + '\nDiscovery uses fresh classic, paired E1/E2 or standalone Ecaz OR Moritani setups with original selected decks, six Discovery territory cards, Great Maker and eight physical tokens in Basic/Advanced2..6. Discovery-tech preserves unused original Tech3..6. Native setup, actual token entry, typed force paths, stash and nested location controls reuse original handlers. No Skills/HW/Nexus/Strongholds, mixed E3 families, new contested/shared-payer rulings, public starts or played-game conversion.'
+ '\nDiscovery-skills selects classic/base33/all14 Skills with original Discovery7/8, Basic/Advanced2..6. Discovery-skills-tech preserves unused original Tech3..6. Original trained posture, physical free entry, fixed-three carried movement, paid nested routes, stash capacity, skill battle rescue/rewards and distinct-party Bureaucrat invoices remain. Native families, Nexus/Strongholds/HW, Banker/Mentat previews, pending contested/shared/mixed effects, public starts and save conversion stay separate.' + '\nDiscovery-skills selects classic/base33/all14 Skills with original Discovery7/8, Basic/Advanced2..6. Discovery-skills-tech preserves unused original Tech3..6. Original trained posture, physical free entry, fixed-three carried movement, paid nested routes, stash capacity, skill battle rescue/rewards and distinct-party Bureaucrat invoices remain. Native families, Nexus/Strongholds/HW, Banker/Mentat previews, pending contested/shared/mixed effects, public starts and save conversion stay separate.'
+ '\nDiscovery-nexus uses classic/base33/all12 Nexus/Discovery7+8, Basic/Advanced2..6. Discovery-nexus-tech adds original Tech3+; discovery-nexus-stronghold adds Advanced Strongholds2+; both requires3+ Advanced. Original Great Maker/worm/vote/ride, settled-alliance end-Spice closing deal, end-Mentat held-card settlement, next-turn entry and source-clear borrowed force/tariff/card effects remain. No Skills/HW/native families, mixed-module Fremen Betrayal, new pending rulings, public starts or save conversion.'
); );
} }
@@ -664,7 +675,7 @@ function freshGame(scenario: Scenario) {
game.homeworlds = { custody: null }; game.homeworlds = { custody: null };
if (scenario.profile === 'paired-nexus-skills' || scenario.profile === 'nexus-skills' || scenario.profile === 'nexus' || nexusModuleProfile(scenario.profile) || scenario.profile === 'homeworld-nexus' || scenario.profile === 'choam-nexus' || scenario.profile === 'ecaz-nexus' || scenario.profile === 'ix-nexus' || scenario.profile === 'combined-nexus' || scenario.profile === 'combined-homeworld-nexus') if (scenario.profile === 'paired-nexus-skills' || scenario.profile === 'nexus-skills' || scenario.profile === 'nexus' || nexusModuleProfile(scenario.profile) || scenario.profile === 'homeworld-nexus' || scenario.profile === 'choam-nexus' || scenario.profile === 'ecaz-nexus' || scenario.profile === 'ix-nexus' || scenario.profile === 'combined-nexus' || scenario.profile === 'combined-homeworld-nexus')
game.nexusCards = { cards: null, phase: null }; game.nexusCards = { cards: null, phase: null };
if (discoverySkillsProfile(scenario.profile)) game.discoveryEnabled = true; if (discoverySkillsProfile(scenario.profile) || discoveryNexusProfile(scenario.profile)) game.discoveryEnabled = true;
if (discoveryProfile(scenario.profile)) { if (discoveryProfile(scenario.profile)) {
game.discoveryEnabled = true; game.discoveryEnabled = true;
return initializeDiscoveryGameForAudit(game); return initializeDiscoveryGameForAudit(game);
@@ -734,9 +745,9 @@ function resumedGame(path: string) {
game.mentatQuestionPreview game.mentatQuestionPreview
) )
throw new Error('--resume sample scenarios exclude unsupported optional modules.'); throw new Error('--resume sample scenarios exclude unsupported optional modules.');
const scenario = [...SCENARIOS, ...COMBINED_NEXUS_SCENARIOS, ...COMBINED_HOMEWORLD_NEXUS_SCENARIOS, ...BASE_SCENARIOS, ...MODULE_SCENARIOS, ...EXPANSION_ROSTER_SCENARIOS, ...ECAZ_TREACHERY_SCENARIOS, ...ECAZ_OCCUPY_SCENARIOS, ...PAIRED_NEXUS_SCENARIOS, ...MORITANI_ASSASSINATE_SCENARIOS, ...STRONGHOLD_FACTIONS_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...IX_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS, ...RICHESE_SKILLS_SCENARIOS, ...ECAZ_SKILLS_SCENARIOS, ...SKILLS_TECH_SCENARIOS, ...SKILLS_STRONGHOLD_SCENARIOS, ...NATIVE_TECH_SCENARIOS, ...CLASSIC_NEXUS_MODULE_SCENARIOS, ...PAIRED_NEXUS_MODULE_SCENARIOS, ...NEXUS_SKILLS_SCENARIOS, ...PAIRED_NEXUS_SKILLS_SCENARIOS, ...NEXUS_SKILLS_MODULE_SCENARIOS, ...PAIRED_NEXUS_SKILLS_MODULE_SCENARIOS, ...E3_NEXUS_SKILLS_SCENARIOS, ...HOMEWORLD_OCCUPATION_SCENARIOS, ...DISCOVERY_SCENARIOS, ...DISCOVERY_SKILLS_SCENARIOS].find( const scenario = [...SCENARIOS, ...COMBINED_NEXUS_SCENARIOS, ...COMBINED_HOMEWORLD_NEXUS_SCENARIOS, ...BASE_SCENARIOS, ...MODULE_SCENARIOS, ...EXPANSION_ROSTER_SCENARIOS, ...ECAZ_TREACHERY_SCENARIOS, ...ECAZ_OCCUPY_SCENARIOS, ...PAIRED_NEXUS_SCENARIOS, ...MORITANI_ASSASSINATE_SCENARIOS, ...STRONGHOLD_FACTIONS_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...IX_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS, ...RICHESE_SKILLS_SCENARIOS, ...ECAZ_SKILLS_SCENARIOS, ...SKILLS_TECH_SCENARIOS, ...SKILLS_STRONGHOLD_SCENARIOS, ...NATIVE_TECH_SCENARIOS, ...CLASSIC_NEXUS_MODULE_SCENARIOS, ...PAIRED_NEXUS_MODULE_SCENARIOS, ...NEXUS_SKILLS_SCENARIOS, ...PAIRED_NEXUS_SKILLS_SCENARIOS, ...NEXUS_SKILLS_MODULE_SCENARIOS, ...PAIRED_NEXUS_SKILLS_MODULE_SCENARIOS, ...E3_NEXUS_SKILLS_SCENARIOS, ...HOMEWORLD_OCCUPATION_SCENARIOS, ...DISCOVERY_SCENARIOS, ...DISCOVERY_SKILLS_SCENARIOS, ...DISCOVERY_NEXUS_SCENARIOS].find(
(candidate) => (candidate) =>
(discoveryProfile(candidate.profile) || discoverySkillsProfile(candidate.profile)) === discovery && (discoveryProfile(candidate.profile) || discoverySkillsProfile(candidate.profile) || discoveryNexusProfile(candidate.profile)) === discovery &&
(candidate.profile === 'homeworld' || candidate.profile === 'homeworld-occupation' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.homeworlds && (candidate.profile === 'homeworld' || candidate.profile === 'homeworld-occupation' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.homeworlds &&
(candidate.profile === 'paired-nexus-skills' || candidate.profile === 'nexus-skills' || candidate.profile === 'nexus' || nexusModuleProfile(candidate.profile) || candidate.profile === 'homeworld-nexus' || candidate.profile === 'choam-nexus' || candidate.profile === 'ecaz-nexus' || candidate.profile === 'ix-nexus' || candidate.profile === 'combined-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.nexusCards && (candidate.profile === 'paired-nexus-skills' || candidate.profile === 'nexus-skills' || candidate.profile === 'nexus' || nexusModuleProfile(candidate.profile) || candidate.profile === 'homeworld-nexus' || candidate.profile === 'choam-nexus' || candidate.profile === 'ecaz-nexus' || candidate.profile === 'ix-nexus' || candidate.profile === 'combined-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.nexusCards &&
(candidate.profile === 'homeworld-occupation') === !!game.homeworldOccupationPreview && (candidate.profile === 'homeworld-occupation') === !!game.homeworldOccupationPreview &&
@@ -962,10 +973,11 @@ async function main() {
throw new Error('Stronghold samples require Advanced rules.'); throw new Error('Stronghold samples require Advanced rules.');
if ((profile === 'skills-tech' || profile === 'skills-stronghold-tech') && players === 2) if ((profile === 'skills-tech' || profile === 'skills-stronghold-tech') && players === 2)
throw new Error('Leader Skills with Tech Tokens samples require three through six players.'); throw new Error('Leader Skills with Tech Tokens samples require three through six players.');
if ((profile === 'discovery-tech' || profile === 'discovery-skills-tech') && players === 2) if ((profile === 'discovery-tech' || profile === 'discovery-skills-tech' || profile === 'discovery-nexus-tech' || profile === 'discovery-nexus-stronghold-tech') && players === 2)
throw new Error('Discovery with Tech Tokens requires three through six players.'); throw new Error('Discovery with Tech Tokens requires three through six players.');
const resume = values.resume ? resumedGame(values.resume) : null; const resume = values.resume ? resumedGame(values.resume) : null;
const requestedSamples = discoverySkillsProfile(profile) ? DISCOVERY_SKILLS_SCENARIOS const requestedSamples = discoveryNexusProfile(profile) ? DISCOVERY_NEXUS_SCENARIOS
: discoverySkillsProfile(profile) ? DISCOVERY_SKILLS_SCENARIOS
: discoveryProfile(profile) ? DISCOVERY_SCENARIOS : discoveryProfile(profile) ? DISCOVERY_SCENARIOS
: profile === 'homeworld-occupation' ? HOMEWORLD_OCCUPATION_SCENARIOS : profile === 'homeworld-occupation' ? HOMEWORLD_OCCUPATION_SCENARIOS
: profile === 'homeworld' || profile === 'nexus' || profile === 'homeworld-nexus' : profile === 'homeworld' || profile === 'nexus' || profile === 'homeworld-nexus'
+2 -2
View File
@@ -102,8 +102,8 @@ export function startPrototypeRoom(
throw new Error('--mentat-question requires the leader-skills profile.'); throw new Error('--mentat-question requires the leader-skills profile.');
if (options.nexusCards && profile !== 'leader-skills') if (options.nexusCards && profile !== 'leader-skills')
throw new Error('--nexus-cards requires the leader-skills profile.'); throw new Error('--nexus-cards requires the leader-skills profile.');
if (options.discoveries && profile !== 'leader-skills') if (options.discoveries && profile !== 'leader-skills' && profile !== 'nexus')
throw new Error('--discoveries requires the leader-skills profile.'); throw new Error('--discoveries requires the leader-skills or nexus profile.');
if (options.ecazTreachery) initial.ecazTreachery = true; if (options.ecazTreachery) initial.ecazTreachery = true;
if (options.mentatQuestion) initial.mentatQuestionPreview = true; if (options.mentatQuestion) initial.mentatQuestionPreview = true;
if ((profile === 'nexus' || options.nexusCards) && !initial.nexusCards) if ((profile === 'nexus' || options.nexusCards) && !initial.nexusCards)
+3 -2
View File
@@ -43,6 +43,7 @@ async function main() {
'\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.', '\nWhen native Ecaz is seated, this original fresh entry separately marks its printed high Homeworld alliance victory: current native seven-plus, one actual joint stronghold, two distinct other native factions with proved current occupied holdings. It adds no ordinary stronghold/Tech points, pays no reward and changes no Duke authority. Public progress remains prospective until original Mentat; qualifying members join original winners before BG prediction/final fallback. Unmarked played/public profiles remain unchanged.',
'\nDiscovery opts fresh classic, selected E1/E2 native families or standalone Ecaz OR Moritani with classics into original Discovery setup in Basic/Advanced2–6, optionally retaining unused original Tech3–6. Original decks/cache, HMS/Face Dancers, typed entry, revealed-only nested No-Fields, stash limits and native outcomes remain. Advanced Moritani excludes Harkonnen. Skills/HW/Nexus/Strongholds, paired/mixed E3, contested/shared-payer rulings, public starts and played-game conversion stay separate.', '\nDiscovery opts fresh classic, selected E1/E2 native families or standalone Ecaz OR Moritani with classics into original Discovery setup in Basic/Advanced2–6, optionally retaining unused original Tech3–6. Original decks/cache, HMS/Face Dancers, typed entry, revealed-only nested No-Fields, stash limits and native outcomes remain. Advanced Moritani excludes Harkonnen. Skills/HW/Nexus/Strongholds, paired/mixed E3, contested/shared-payer rulings, public starts and played-game conversion stay separate.',
'\nLeader-skills --discoveries explicitly composes fresh classic/base33/all14 Skills with original Discovery7/8 at2–6 seats Basic/Advanced. Unused original Tech requires3–6. Original setup, typed free entry, paid nested routes, carried fixed-three movement, stash limits, trained battles and Bureaucrat invoices use existing controls. Native families, Nexus/Strongholds/HW, Banker/Mentat previews, pending rulings, public starts and save conversion are not opened.', '\nLeader-skills --discoveries explicitly composes fresh classic/base33/all14 Skills with original Discovery7/8 at2–6 seats Basic/Advanced. Unused original Tech requires3–6. Original setup, typed free entry, paid nested routes, carried fixed-three movement, stash limits, trained battles and Bureaucrat invoices use existing controls. Native families, Nexus/Strongholds/HW, Banker/Mentat previews, pending rulings, public starts and save conversion are not opened.',
'\nNexus --discoveries explicitly composes fresh classic/base33/all12 Nexus with original Discovery7/8 at2–6 seats Basic/Advanced. Unused Tech requires3+; optional Strongholds require Advanced; both3+. Actual Great Maker/worm/vote/reserve ride, original settled-alliance end-Spice closing card deal and end-Mentat card ownership/next entry remain. Source-clear revival/tariff/battle effects reuse original handlers. Skills/HW/native families, mixed-module Fremen Betrayal, pending rulings, public activation and save conversion stay guarded.',
); );
return; return;
} }
@@ -52,8 +53,8 @@ async function main() {
throw new Error('--mentat-question requires --profile leader-skills.'); throw new Error('--mentat-question requires --profile leader-skills.');
if (values['nexus-cards'] && values.profile !== 'leader-skills') if (values['nexus-cards'] && values.profile !== 'leader-skills')
throw new Error('--nexus-cards requires --profile leader-skills.'); throw new Error('--nexus-cards requires --profile leader-skills.');
if (values.discoveries && values.profile !== 'leader-skills') if (values.discoveries && values.profile !== 'leader-skills' && values.profile !== 'nexus')
throw new Error('--discoveries requires --profile leader-skills.'); throw new Error('--discoveries requires --profile leader-skills or nexus.');
if ( if (
!isPrototypeProfile(values.profile) || !isPrototypeProfile(values.profile) ||
!values.db || !values.db ||