Compose native Homeworld Nexus optional modules
This commit is contained in:
1 parent
71bc325289
commit
2efa10a9d6
19 files changed
+1961
-62
No files matched your search
@@ -17,6 +17,30 @@ open mode gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
**Native Homeworld Nexus — bounded changed-path proof.** Existing paired
|
||||
E1/E2 Skills/no-Skills module/Discovery and standalone Ecaz OR Moritani
|
||||
Skills entries retain original Homeworld/Nexus with optional Discovery/
|
||||
Tech/Advanced Strongholds.24 original admissions reject before and pass
|
||||
after; actual setup/private offers/native custody and ruling gates remain.
|
||||
New15 meaningful cases and the affected25-file union307/307 pass.
|
||||
Across23 families,59 of68 original six-seat games finish71,137 accepted/
|
||||
71,484 attempted actions with1,894 JSON on one unchanged source tree;
|
||||
nine captures retain seven low-CHOAM opening-income, Basic odd-force Ecaz
|
||||
and high-threshold revival timing guards. No guard bypass or68-green claim.
|
||||
|
||||
Actual390px original no-Skills `AB7UKV8K`v145 randomly reveals Cistern,
|
||||
finishes Maker/both piles/alliance before native Richese draw, then commits
|
||||
5+3 Cunning for one-spice invoice: reserves12/Cistern8/Tanks0, wallet4.
|
||||
Original `8Q2WZQMA`v88 chooses real offered Warmaster/Sanya, pays for native
|
||||
Wallach visitor1, then spends Ecaz for the living Duke with unchanged
|
||||
purse12 and original training. Both inspectors show original20 counters;
|
||||
fresh choice/result screenshots and document390 are observed.
|
||||
[Canonical source/programme receipts](HOMEWORLD_RUNTIME_IMPLEMENTATION.md#native-homeworld-nexus-composition--6-october-2026).
|
||||
Final types/lint/build and compiled390px guide pass. All managed tabs close
|
||||
and temporary scripts are removed; checkpoint/exact-revision receipt follows.
|
||||
Other native families, pending rulings, full-mode acceptance and protected
|
||||
deployment stay open.
|
||||
|
||||
**Classic Homeworld Nexus — bounded Development Verified.** Fresh original
|
||||
classic Basic/Advanced2–6 now composes Homeworlds/all12 with all14 Skills,
|
||||
Discovery7/8 or both, optional Tech3+/Advanced Strongholds2+. No-Skills/
|
||||
|
||||
@@ -5,6 +5,26 @@ partial rules coverage. It connects physical components, real actions, private
|
||||
controls, AI choices and saved continuation. Normal public Discovery and
|
||||
Advanced start gates, and the publication gate, remain closed.
|
||||
|
||||
## Native Homeworld Nexus composition — 6 October 2026
|
||||
|
||||
Original paired Ixians+Tleilaxu OR CHOAM+Richese Nexus/Discovery entries,
|
||||
with or without Skills, now retain Homeworlds and optional Tech3+/Advanced
|
||||
Strongholds2+. Standalone Ecaz OR Moritani/classic Homeworld/Nexus composes
|
||||
Discovery with Skills. Existing original envelopes, private offers,
|
||||
parent/free entry, Maker losses/votes/rides, both Advanced piles and
|
||||
settled-alliance closing draws remain authoritative.
|
||||
|
||||
Actual human supply yields Cistern in the original no-Skills Richese room.
|
||||
Guild's paid native3 parent army freely enters after the real first Mentat.
|
||||
The human draws the actual Richese singleton at the qualifying close, then
|
||||
commits its native5+3 Cunning pair inside that revealed site for one spice:
|
||||
native reserves12, Cistern8, wallet4, no repeated payment/materialization.
|
||||
Typed Suk, residual Cyborg substitution, CHOAM support income and original
|
||||
cleanup/winner Tech retain separate timing and source-local benefits.
|
||||
[Fifteen new cases,307 affected checks,23-family game receipts and human proof](HOMEWORLD_RUNTIME_IMPLEMENTATION.md#native-homeworld-nexus-composition--6-october-2026).
|
||||
Other native families, pending effects, public starts and full-mode
|
||||
acceptance remain separate.
|
||||
|
||||
## Classic Homeworld Nexus composition — 6 October 2026
|
||||
|
||||
The original classic Nexus/Discovery entry now retains Homeworlds with
|
||||
|
||||
@@ -6,6 +6,106 @@ disabled.** It does not certify Homeworld battles, occupation, card effects or
|
||||
complete Advanced/expansion games. The earlier component checkpoint remains
|
||||
in [HOMEWORLD_IMPLEMENTATION.md](HOMEWORLD_IMPLEMENTATION.md).
|
||||
|
||||
## Native Homeworld Nexus composition — 6 October 2026
|
||||
|
||||
Fresh original paired Ixians+Tleilaxu OR CHOAM+Richese entries now retain
|
||||
Homeworlds/all12 Nexus with Skills, Discovery or both and optional original
|
||||
Tech3+/Advanced Strongholds2+. The no-Skills/no-Discovery paired module entry
|
||||
retains Tech and/or Advanced Strongholds. Standalone Ecaz OR Moritani with
|
||||
classic opponents retains Homeworld/Nexus with all14 Skills, optionally
|
||||
Discovery7/8 and those industry/card modules. Basic/Advanced2–6 follows
|
||||
each existing roster predicate; Advanced Harkonnen and Duke/assassination
|
||||
exclusions remain. Native singles/mixed-deck Nexus and no-Skills E3 module
|
||||
families remain separate, not outside the complete goal.
|
||||
|
||||
The existing private skill/Nexus, paired Nexus module and paired Discovery/
|
||||
Nexus envelopes are amended; original setup/initializers remain. Actual
|
||||
Ixian pre-training offers precede native placement. Richese Cunning now
|
||||
uses these existing Homeworld paired envelopes, not an Arrakis-only
|
||||
fallback. No new revival/deposit adapter, public start or played-save
|
||||
conversion is added. Revision physical PDF21–23 permits optional variants
|
||||
together/separately regardless of seated factions; printed Nexus faces
|
||||
retain their separate effect authority and pending interpretations.
|
||||
|
||||
Fifteen meaningful cases in two original programme pairs and the affected
|
||||
25-file union307/307 pass. Typed native/visitor Suk, residual Cyborg Tank
|
||||
substitution, nested single-invoice marker materialization, held support,
|
||||
original cleanup/Tech, Moritani assassination/death/own revival and quiet
|
||||
living-Duke Cunning retain original physical sources. Foreign-Homeworld
|
||||
Face Dance suppression remains; Ecaz Arrakis Occupy does not become
|
||||
Homeworld Occupy or Intrusion. All four minimal legal policies consume real
|
||||
owned windows. Free native strength is not free dialed-force support;
|
||||
CHOAM support income settles after Suk/cleanup and excludes its own plan.
|
||||
|
||||
Fixture corrections use actual leader-death rewards, typed rescue returns,
|
||||
affordable declarations, real turn-six Charity and available untrained
|
||||
later-battle leaders after posture closes. No wallets, forces, deaths or
|
||||
outcomes are injected. An older copied-hand/staged-army test with
|
||||
“authenticated” player names but no HTTP authentication is deleted, not
|
||||
re-pinned; actual original HTTP/private-CLI human evidence follows below.
|
||||
|
||||
Twenty-three new runner profiles retain stable bands10300–12500, leaving
|
||||
older names/seeds unchanged. Families are `paired-homeworld-nexus-skills`,
|
||||
`paired-homeworld-nexus-discovery`, `paired-homeworld-nexus-discovery-skills`,
|
||||
`e3-homeworld-nexus-skills` and `e3-homeworld-nexus-discovery-skills`, each
|
||||
optionally `-tech`, `-stronghold` or `-stronghold-tech`; no-Skills/no-Discovery
|
||||
paired profiles are `paired-homeworld-nexus-tech`, `-stronghold` and
|
||||
`-stronghold-tech`. Original prerequisite guards and exact resume dispatch
|
||||
remain authoritative.
|
||||
|
||||
Across all23 families,59 of68 original six-seat Basic/Advanced samples
|
||||
complete71,137 accepted/71,484 attempted actions with1,894 JSON continuations
|
||||
on unchanged source tree
|
||||
`d5083acd4842b98e6ccdb753354c36b50f840726248d6d952db8c8835478509e`
|
||||
(HEAD `71bc325`). Every report's before/after receipt is read. Nine original
|
||||
captures retain existing guards: seven low-CHOAM opening-income cases,
|
||||
one Basic odd-**force** Ecaz Occupy and one Imperial revival crossing a
|
||||
Homeworld high threshold. Their actual rejected trace reasons are read;
|
||||
none is bypassed or rerun to confirm. This is not68 uninterrupted completions
|
||||
or a later documentation-tree certificate. Reports and captures remain at
|
||||
`/tmp/dune-native-{profile}-six-seat-20261006`, seed20268006.
|
||||
|
||||
Human390px `AB7UKV8K` uses its original four-human Richese/CHOAM/Guild/Fremen
|
||||
HTTP lobby and original `nexus --discoveries` CLI, with Homeworld/Tech/Strongholds
|
||||
selected beforehand. Original Traitors and legal Fremen placement complete.
|
||||
Only conserved original unplayed Spice/Nexus order is selected; the actual
|
||||
supply lottery yields Cistern, not a staged token. Guild's native3 paid
|
||||
shipment, actual peek/reveal and next-turn free parent entry precede real
|
||||
Maker majority, both Advanced piles, Guild/Fremen alliance and closing draw.
|
||||
The human draws the actual Richese singleton, then uses the Cunning surface
|
||||
to reveal5 and conceal3 inside Cistern. Original Guild approval commits
|
||||
one-spice invoice: wallet5→4, native reserves20→15 and Cistern5 plus the
|
||||
concealed marker. Actual human revelation materializes the other3: atv145,
|
||||
native reserves12/Cistern8, Tanks0, no concealed marker, wallet4 unchanged.
|
||||
The fresh public inspector shows20 physical counters, not marker strength.
|
||||
|
||||
Human390px `8Q2WZQMA` uses its original Ecaz/Guild/Bene Gesserit lobby and
|
||||
`leader-skills --nexus-cards` CLI with Homeworld/Tech/Strongholds. Actual
|
||||
Prediction precedes its random Diplomat/Warmaster offer; the human chooses
|
||||
Warmaster on the original eligible Sanya Ecaz disc without redealing.
|
||||
Both other actual trainings and native placement complete. Real first turn,
|
||||
both second-turn piles, Guild/BG alliance and closing draw precede the
|
||||
human Ecaz singleton draw. The human ships1 actual native counter to
|
||||
Wallach IX for1; reserves14→13, separate visitor1, BG native19 unchanged.
|
||||
At the actual quiet Battle window the human spends Ecaz Nexus for the
|
||||
living unclaimed Duke. Atv88, Duke control is Ecaz, the card is gone, original
|
||||
Sanya/Warmaster remains alive and uncaptured, and the v87/v88 purse stays12.
|
||||
Ordinary phase-opening income is separate from the shipment/Cunning.
|
||||
The fresh inspector shows13 native reserves, Imperial Basin6 and Wallach1.
|
||||
|
||||
Fresh choice/result accessibility and screenshots observe both original
|
||||
390px surfaces with document width390. These bounded programmes do not
|
||||
certify complete modes, all combinations, comprehensive assurance,
|
||||
calibration, pending rulings or protected deployment.
|
||||
|
||||
Final types/lint/build pass. The first guide load after rebuilding beneath
|
||||
the owned QA worker fails its old lazy-chunk request; an owned-only worker
|
||||
restart loads the new native composition and307-check evidence at390px.
|
||||
The originalv145/v88 changed-path states retain their counters, Duke and
|
||||
training. All three managed tabs close; both temporary TypeScript helpers
|
||||
are removed. Private source reports, captures and credential-free human
|
||||
action/result receipts remain outside Git.
|
||||
|
||||
## Classic Homeworld Nexus composition — 6 October 2026
|
||||
|
||||
Fresh classic Basic/Advanced2–6 now composes original Homeworlds/all12 Nexus
|
||||
|
||||
@@ -1,5 +1,40 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 6 October 2026 — Native Homeworld Nexus optional modules
|
||||
|
||||
Original paired E1/E2 skill/Nexus and no-Skills Nexus module/Discovery
|
||||
envelopes, plus standalone Ecaz OR Moritani skill/Nexus, now retain
|
||||
Homeworlds with optional Discovery/Tech/Advanced Strongholds. Original
|
||||
setup/private offers/native custody remain. Richese Cunning uses those
|
||||
existing Homeworld paired profiles; no second initializer or revival
|
||||
adapter, public start, played-save conversion or new ruling is added.
|
||||
Twenty-three native runner profiles preserve older names/seeds and
|
||||
prerequisite/resume dispatch.
|
||||
|
||||
New15 meaningful cases; affected307/307 across25 files. Fixture corrections
|
||||
use actual pending CHOAM income, physical rescue returns, leader-death
|
||||
rewards, legal funded declarations/Charity and available untrained leaders
|
||||
after posture closes. A fake-auth-name/copied-hand/staged-army test is
|
||||
deleted, not re-pinned. Across all23 families,59 of68 original six-seat
|
||||
games finish71,137 accepted/71,484 attempted actions with1,894 JSON on one
|
||||
unchanged source tree. Nine preserved captures reach existing rulings:
|
||||
seven CHOAM low-opening income, Basic Ecaz odd-force rounding and an
|
||||
Imperial revival crossing a Homeworld high threshold.
|
||||
|
||||
Actual390px original `AB7UKV8K`v145 draws native Richese after real Maker/
|
||||
both-pile/alliance settlement; native5+3 Cistern Cunning pays one spice once:
|
||||
reserves12/Cistern8/Tanks0, wallet4, no concealed marker. Original
|
||||
`8Q2WZQMA`v88 chooses its random Warmaster/Sanya offer, pays for one native
|
||||
Wallach visitor, then spends Ecaz Nexus for the living Duke with original
|
||||
training intact and unchanged Cunning purse12. Choice/result screenshots,
|
||||
fresh state and document390 prove these bounded human paths.
|
||||
Final types/lint/build and compiled390px guide pass. An observed old lazy
|
||||
chunk after rebuilding under the owned QA worker clears after its restart;
|
||||
originalv145/v88 states retain their changed-path custody. All tabs close
|
||||
and our temporary TypeScript helpers are removed; private receipts remain.
|
||||
[Exact programme/source receipts and retained boundaries](HOMEWORLD_RUNTIME_IMPLEMENTATION.md#native-homeworld-nexus-composition--6-october-2026).
|
||||
No complete-mode, comprehensive assurance, calibration or protected-deployment claim.
|
||||
|
||||
## 6 October 2026 — Classic Homeworld Nexus optional modules
|
||||
|
||||
Original classic Homeworld/Nexus now composes all14 Skills, Discovery7/8 or
|
||||
|
||||
@@ -5,6 +5,27 @@ Normal mode starts and publication remain gated. The [source contract](LEADER_SK
|
||||
contains all fourteen physical cards; the [capture interpretation](LEADER_SKILLS_CAPTURE.md)
|
||||
records the fresh publisher/designer search requested by the user.
|
||||
|
||||
## Native Homeworld Nexus composition — 6 October 2026
|
||||
|
||||
Original paired Ixians+Tleilaxu OR CHOAM+Richese and standalone Ecaz OR
|
||||
Moritani/classic skill-Nexus entries now retain Homeworlds, optional
|
||||
Discovery7/8, Tech3+ and Advanced Strongholds2+. Original all14 offers,
|
||||
decks/cache, native placement, ordinary-disc training and Duke/assassination
|
||||
restrictions remain. Existing physical Suk/substitution/cleanup/Tech order
|
||||
and source-local native returns remain; foreign-Homeworld Face Dance stays
|
||||
suppressed. Quiet Duke Cunning does not train the shared disc or create
|
||||
Homeworld Occupy/Intrusion.
|
||||
|
||||
Fifteen meaningful original cases,307/307 affected tests and59 of68
|
||||
original six-seat games across23 families retain source-qualified evidence.
|
||||
Actual390px Ecaz chooses its randomly offered Warmaster on Sanya, draws
|
||||
Ecaz after real alliance settlement, pays for one native Wallach visitor,
|
||||
then spends the singleton for the living Duke without changing its purse
|
||||
or original trained disc.
|
||||
[Canonical programmes, source receipts and retained ruling captures](HOMEWORLD_RUNTIME_IMPLEMENTATION.md#native-homeworld-nexus-composition--6-october-2026).
|
||||
Other native families, pending effects, public starts, comprehensive
|
||||
assurance, calibration and complete-mode acceptance remain open.
|
||||
|
||||
## Classic Homeworld Nexus composition — 6 October 2026
|
||||
|
||||
Fresh classic all14 Skills/all12 Nexus now retains original Homeworlds,
|
||||
|
||||
@@ -2,6 +2,26 @@
|
||||
|
||||
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.
|
||||
|
||||
## Native Homeworld Nexus composition — 6 October 2026
|
||||
|
||||
Existing paired Ixians+Tleilaxu OR CHOAM+Richese entries retain original
|
||||
Homeworlds with Skills, Discovery or both, optional Tech3+/Advanced
|
||||
Strongholds2+, and no-Skills/no-Discovery module entries retain the
|
||||
industry/card modules. Standalone Ecaz OR Moritani/classic Homeworld/Nexus
|
||||
retains Skills with optional Discovery/Tech/Strongholds. Original family
|
||||
decks, first offers/hands, native placement and printed effect boundaries
|
||||
remain. Existing profiles/initializers are amended, not duplicated.
|
||||
|
||||
Actual native effects keep their sources: same-quote Suk/residual Cyborg
|
||||
substitution, signed Richese pair/one invoice, delayed CHOAM support income,
|
||||
quiet living-Duke custody and skill-first Moritani assassination/own revival.
|
||||
Foreign-Homeworld Face Dance remains suppressed; free native battle strength
|
||||
does not waive dialed-force support. No public start, save conversion,
|
||||
pending ruling or unsupported roster family is opened.
|
||||
[Canonical15-case/307-check,23-family original-game and two human receipts](HOMEWORLD_RUNTIME_IMPLEMENTATION.md#native-homeworld-nexus-composition--6-october-2026)
|
||||
records59 of68 completions and nine preserved source-ruling captures, not
|
||||
complete Nexus/Homeworld compliance or deployed acceptance.
|
||||
|
||||
## Native trained Skills/Nexus/Discovery — 6 October 2026
|
||||
|
||||
The original fresh `leader-skills --nexus-cards --discoveries` entry now
|
||||
|
||||
@@ -2,6 +2,26 @@
|
||||
|
||||
Updated 2 October 2026. This is the authoritative navigation index for existing decisions, not a replacement rulebook or a new ruling. Detailed linked contracts retain their source citations and exact supported boundaries. Runtime follow-ups supersede historical absence claims; passing tests establish software behavior, not publisher authority.
|
||||
|
||||
## Native Homeworld Nexus composition — 6 October 2026
|
||||
|
||||
Authorized revision physical PDF21–23 permits optional variants together
|
||||
or separately regardless of factions; this admits existing paired E1/E2
|
||||
and standalone Ecaz OR Moritani skill/Nexus Homeworld envelopes rather than
|
||||
establishing a general incompatibility. Original profile/printed-face
|
||||
restrictions remain.
|
||||
[Source-qualified runtime evidence](HOMEWORLD_RUNTIME_IMPLEMENTATION.md#native-homeworld-nexus-composition--6-october-2026)
|
||||
keeps native Suk returns at the same home, residual Cyborg Tanks from that
|
||||
same physical quote and native Homeworld strength separate from paid dialed
|
||||
support. CHOAM income settles after rescue/cleanup and excludes its own support.
|
||||
Actual Richese nested materialization and quiet living-Duke custody add no
|
||||
free companion, shared-disc training, Homeworld Occupy or Intrusion.
|
||||
|
||||
Nine original captures retain existing low-CHOAM opening-income, Basic
|
||||
odd-force Ecaz and high-threshold revival timing guards. No cancellation,
|
||||
payout, force-count rewrite or new ruling bypasses them. Other recorded
|
||||
Face Dance allocation, Atreides Suk/KH, Diplomat retreat and native return
|
||||
questions stay separate. Passing software checks does not settle sources.
|
||||
|
||||
## Classic Homeworld Skills/Discovery composition — 6 October 2026
|
||||
|
||||
Authorized revision physical PDF21–23 permits these variants together or
|
||||
|
||||
@@ -95,14 +95,14 @@ export function classicDiscoveryNexusProfile(game: FactionModuleProfile): boolea
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Both original natives in one E1/E2 Nexus/Discovery family, without Skills.
|
||||
/** Both original natives in one E1/E2 Nexus/Discovery family, optionally Homeworlds, without Skills.
|
||||
* Original setup owns undealt inventories; live Discovery frames are expected. */
|
||||
export function pairedDiscoveryNexusProfile(game: FactionModuleProfile): boolean {
|
||||
if (typeof game.advanced !== 'boolean' || game.discoveryEnabled !== true ||
|
||||
!game.nexusCards || game.expansions.length !== 1 ||
|
||||
game.players.length < 2 || game.players.length > 6 ||
|
||||
(game.techTokens && game.players.length < 3) ||
|
||||
(game.strongholdCards && !game.advanced) || game.leaderSkills || game.homeworlds ||
|
||||
(game.strongholdCards && !game.advanced) || game.leaderSkills ||
|
||||
game.ecazTreachery || game.semutaPreview || game.advancedPreview || game.kullPreview ||
|
||||
game.nexusKullPreview || game.guildBetrayalPreview || game.richeseBetrayalPreview ||
|
||||
game.nexusIxianReplacementPreview || game.nexusIxianBetrayalPreview ||
|
||||
|
||||
+6
-4
@@ -9325,15 +9325,16 @@ export function initializePairedNexusGameForAudit(state: Game): Game {
|
||||
'The paired Nexus sample requires both selected expansion factions and their deck.');
|
||||
const discovery = state.discoveryEnabled === true && pairedDiscoveryNexusProfile(state);
|
||||
requireRule(!!state.nexusCards && state.nexusCards.cards === null && state.nexusCards.phase === null &&
|
||||
!state.homeworlds && !state.leaderSkills && !state.ecazTreachery &&
|
||||
(!state.homeworlds || expansion === 'ix' || expansion === 'choam') &&
|
||||
!state.leaderSkills && !state.ecazTreachery &&
|
||||
(!state.discoveryEnabled || discovery) &&
|
||||
((!state.techTokens && !state.strongholdCards) || discovery || pairedNexusModulesProfile(state)),
|
||||
'Enable Nexus Cards with only supported unused paired E1/E2 Discovery, Tech or Advanced Stronghold components.');
|
||||
'Enable Nexus Cards with supported original paired E1/E2 Homeworlds, Discovery, Tech or Advanced Stronghold components.');
|
||||
if (state.discoveryEnabled) {
|
||||
requireFreshBaseRuntime(state);
|
||||
requireFreshFactionInventory(state);
|
||||
}
|
||||
return initializeSetupGameForAudit(state, false, true, false, discovery, false, false, true);
|
||||
return initializeSetupGameForAudit(state, !!state.homeworlds, true, false, discovery, false, false, true);
|
||||
}
|
||||
|
||||
/** Genuine opt-in auction source interruption; ordinary/public starts stay gated. */
|
||||
@@ -9540,7 +9541,7 @@ export function initializeLeaderSkillsGameForAudit(state: Game): Game {
|
||||
const homeworlds = homeworldLeaderSkillsProfile(state);
|
||||
if (state.homeworlds) {
|
||||
requireRule(homeworlds && state.homeworlds.custody === null,
|
||||
'Homeworld Leader Skills require a fresh supported classic or native lobby with original optional modules; Nexus composition requires classic factions.');
|
||||
'Homeworld Leader Skills require a fresh supported classic, paired E1/E2 or standalone E3 lobby with original optional modules and undealt native custody.');
|
||||
requireFreshBaseRuntime(state);
|
||||
requireFreshFactionInventory(state);
|
||||
}
|
||||
@@ -25743,6 +25744,7 @@ const RICHESE_CUNNING_ROSTER: Partial<Record<FactionId, true>> = {
|
||||
function richeseCunningModeSupported(g: Game) {
|
||||
const skills = !!g.leaderSkills && pairedNexusLeaderSkillsProfile(g);
|
||||
return pairedDiscoveryNexusProfile(g) ||
|
||||
(!!g.homeworlds && (skills || pairedNexusModulesProfile(g))) ||
|
||||
(!g.homeworlds && (!g.leaderSkills || skills) && (!g.discoveryEnabled || skills) &&
|
||||
!g.ecazTreachery && !g.sandtrout &&
|
||||
g.players.every(player => RICHESE_CUNNING_ROSTER[player.faction] === true));
|
||||
|
||||
@@ -37,7 +37,6 @@ export function noOtherLeaderSkillModules(game: LeaderSkillProfile): boolean {
|
||||
function nexusLeaderSkillModulesSupported(game: LeaderSkillProfile, assassination = false, discovery = false): boolean {
|
||||
return !!game.nexusCards && typeof game.advanced === 'boolean' &&
|
||||
!!game.players && game.players.length >= 2 && game.players.length <= 6 &&
|
||||
(!game.homeworlds || game.expansions.length === 0) &&
|
||||
((discovery && game.discoveryEnabled === true) ||
|
||||
(!game.discoveries && !game.discoveryEnabled && !game.discoveryStash && !game.greatMaker)) &&
|
||||
(!game.techTokens || game.players.length >= 3) &&
|
||||
@@ -65,7 +64,7 @@ export function classicNexusLeaderSkillsProfile(game: LeaderSkillProfile): boole
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Both original natives in one E1/E2 family, with original Discovery/Tech/Advanced Strongholds. */
|
||||
/** Both original natives in one E1/E2 family, optionally Homeworlds/Discovery/Tech/Strongholds. */
|
||||
export function pairedNexusLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
|
||||
if (game.expansions.length !== 1 || !nexusLeaderSkillModulesSupported(game, false, true)) return false;
|
||||
const expansion = game.expansions[0];
|
||||
@@ -86,7 +85,7 @@ export function pairedNexusLeaderSkillsProfile(game: LeaderSkillProfile): boolea
|
||||
return hasPrimary && hasSecondary;
|
||||
}
|
||||
|
||||
/** One original E3 native with classics and original Discovery; no shared-Duke assignment or mixed family. */
|
||||
/** One original E3 native with classics and optional Homeworlds/Discovery; no shared Duke or mixed family. */
|
||||
export function standaloneE3NexusLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
|
||||
if (game.expansions.length !== 1 || game.expansions[0] !== 'ecaz' ||
|
||||
!nexusLeaderSkillModulesSupported(game, game.advanced === true, true)) return false;
|
||||
@@ -385,7 +384,8 @@ function homeworldLeaderSkillModulesSupported(game: LeaderSkillProfile): boolean
|
||||
const players = game.players;
|
||||
if (!game.homeworlds || typeof game.advanced !== 'boolean' ||
|
||||
!players || players.length < 2 || players.length > 6 ||
|
||||
(game.nexusCards && !classicNexusLeaderSkillsProfile(game)) ||
|
||||
(game.nexusCards && !classicNexusLeaderSkillsProfile(game) &&
|
||||
!pairedNexusLeaderSkillsProfile(game) && !standaloneE3NexusLeaderSkillsProfile(game)) ||
|
||||
(game.discoveryEnabled !== true && (game.discoveryEnabled || game.discoveries || game.discoveryStash || game.greatMaker)) ||
|
||||
(game.techTokens && players.length < 3) || (game.strongholdCards && !game.advanced) ||
|
||||
game.ecazTreachery ||
|
||||
@@ -399,7 +399,7 @@ function homeworldLeaderSkillModulesSupported(game: LeaderSkillProfile): boolean
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Original Homeworld/Skills; only classic rosters additionally compose Nexus. */
|
||||
/** Original Homeworld/Skills under the existing classic or native Nexus roster contracts. */
|
||||
export function homeworldLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
|
||||
if (!game.homeworlds) return false;
|
||||
if (game.expansions.length) return nativeExpansionLeaderSkillsProfile(game);
|
||||
|
||||
@@ -26,14 +26,14 @@ export function classicNexusModulesProfile(game: FactionModuleProfile): boolean
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Both original natives in one E1 or E2 family; mixed decks/families stay separate. */
|
||||
/** Both original E1/E2 natives, optionally Homeworlds; mixed decks/families stay separate. */
|
||||
export function pairedNexusModulesProfile(game: FactionModuleProfile): boolean {
|
||||
if (game.discoveryEnabled || game.discoveries) return pairedDiscoveryNexusProfile(game);
|
||||
if (typeof game.advanced !== 'boolean' || !game.nexusCards ||
|
||||
(!game.techTokens && !game.strongholdCards) || game.expansions.length !== 1 ||
|
||||
game.players.length < 2 || game.players.length > 6 ||
|
||||
(game.techTokens && game.players.length < 3) ||
|
||||
(game.strongholdCards && !game.advanced) || game.leaderSkills || game.homeworlds ||
|
||||
(game.strongholdCards && !game.advanced) || game.leaderSkills ||
|
||||
game.discoveryEnabled || game.discoveries || game.discoveryStash || game.greatMaker ||
|
||||
game.ecazTreachery || game.semutaPreview || game.advancedPreview ||
|
||||
game.kullPreview || game.nexusKullPreview || game.guildBetrayalPreview ||
|
||||
|
||||
+11
-8
@@ -3820,7 +3820,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
).join(' '),
|
||||
steps: [
|
||||
'Homeworlds are an optional module from Ecaz & Moritani, available under the official rules with Basic or Advanced play. They are separate locations outside Arrakis; they do not receive ordinary storm movement or count as ordinary strongholds.',
|
||||
'Fresh private Homeworld prototypes compose all fourteen Leader Skills, Discovery seven cards/eight tokens or both, Basic/Advanced two through six classic or supported native E1/E2 seats, or standalone Ecaz OR Moritani with classic opponents. Original family decks and Duke/assassination exclusions remain. Optional original Tech requires three or more seats; Stronghold Cards require Advanced. Classic factions may additionally retain all12 Nexus through the existing skill or Nexus entry. Original setup/native placement, phase-end industry, source-local held cards and cleanup before mandatory winner Tech remain. Typed native Suk saves stay at the same home; external Emperor returns explicitly allocate normal/elite counters to actual native homes, an implementation interpretation rather than ordinary revival placement. Normal revival separately deposits Sardaukar at Salusa. Nested battles receive no Homeworld bonus; later Cistern income is separate from support/rescue. Native Homeworld/Nexus, unsupported E3 pairs/mixed families, unrelated overlays, public starts and pending interactions stay separate.',
|
||||
'Fresh private Homeworld prototypes compose all fourteen Leader Skills, Discovery seven cards/eight tokens or both, Basic/Advanced two through six classic or supported native E1/E2 seats, or standalone Ecaz OR Moritani with classic opponents. Original family decks and Duke/assassination exclusions remain. Optional original Tech requires three or more seats; Stronghold Cards require Advanced. Classic or paired Ixians+Tleilaxu/CHOAM+Richese entries may additionally retain all12 Nexus through the existing skill or Nexus entry. Standalone Ecaz OR Moritani retains Homeworld/Nexus only with Skills in this bounded entry. Original setup/native placement, phase-end industry, source-local held cards and cleanup before mandatory winner Tech remain. Typed native Suk saves stay at the same home; external Emperor returns explicitly allocate normal/elite counters to actual native homes, an implementation interpretation rather than ordinary revival placement. Normal revival separately deposits Sardaukar at Salusa. Nested battles receive no Homeworld bonus; later Cistern income is separate from support/rescue. Other native singles/mixed-deck Nexus, unsupported E3 pairs/mixed families, unrelated overlays, public starts and pending interactions stay separate.',
|
||||
'Each faction uses its own card and matching world token. In Basic play the Emperor uses Kaitain alone. Advanced play adds Salusa Secundus for its Sardaukar; movement can transfer both force types between the two Emperor worlds.',
|
||||
'Native reserves determine high or low population. Salusa counts Sardaukar, while the other cards count the faction’s reserves there. Reaching the high face’s minimum restores high population. Occupation also applies the low penalty.',
|
||||
'Low population normally grants one additional free force revival and one additional bank spice when collecting CHOAM Charity. Salusa grants neither bonus; CHOAM’s Tupile reverses the usual pattern by granting an advantage while low and a penalty while high.',
|
||||
@@ -3984,7 +3984,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
area: 'Verification',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'Classic Homeworld Nexus optional modules add13 meaningful cases; affected242/242, types/lint/build pass. Twenty-two original six-seat games across15 families finish14233/14233 actions with375 JSON on one unchanged source tree. Actual390px typed invasion/Suk/fixed-three return and random Shrine/Maker/both-pile closing draw retain source custody. Native35-case, repaired Ambassador captures and earlier classic/occupation evidence remain in canonical source logs. Not complete rules, all combinations, comprehensive assurance/calibration or deployed acceptance.',
|
||||
'Native Homeworld Nexus adds15 meaningful cases; the affected25-file union passes307/307. Across23 native families,59 of68 original six-seat games finish71137 accepted/71484 attempted actions with1894 JSON on one unchanged source tree; nine preserved captures reach existing source rulings. Actual390px original Richese nested two-marker Cunning pays one invoice and materializes8 counters; Ecaz keeps actual Warmaster training through paid native invasion and living-Duke Cunning. Earlier classic and native evidence remains in canonical logs. Not complete rules, all combinations, comprehensive assurance/calibration or deployed acceptance.',
|
||||
evidence: [
|
||||
'tests/homeworld-classic-skills-runtime.test.ts',
|
||||
'tests/homeworld-classic-discovery-runtime.test.ts',
|
||||
@@ -3994,6 +3994,8 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'tests/homeworld-native-e3-modules-runtime.test.ts',
|
||||
'tests/homeworld-classic-nexus-skills-runtime.test.ts',
|
||||
'tests/homeworld-classic-discovery-nexus-runtime.test.ts',
|
||||
'tests/homeworld-paired-nexus-runtime.test.ts',
|
||||
'tests/homeworld-standalone-nexus-runtime.test.ts',
|
||||
'tests/homeworld-custody.test.ts',
|
||||
'tests/homeworld-cards.test.ts',
|
||||
'tests/homeworld-payment-income.test.ts',
|
||||
@@ -4089,10 +4091,10 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
searchText: 'Hiereg Smuggler Jacurutu Sietch Cistern Ecological Testing Station Shrine Orgiz Processing Station Treachery Card Stash Spice Stash Ornithopter',
|
||||
steps: [
|
||||
'Fresh Discovery development setup supports classic, selected Ixian/Tleilaxu/CHOAM/Richese families, or standalone Ecaz or Moritani with classic opponents in Basic or Advanced, with seven extra Spice Cards and eight tokens. Original unused Tech Tokens may be selected with three through six seats; original Stronghold Cards require Advanced. Other modules, paired or mixed Ecaz/Moritani families and normal public starts stay gated.',
|
||||
'Fresh Discovery retains Homeworlds in Basic/Advanced two through six classic or supported native E1/E2 seats, or standalone Ecaz OR Moritani with classic opponents, original seven cards/eight tokens and native reserve placement. Original Tech requires three or more seats; Stronghold Cards require Advanced. The separate original leader-skills --discoveries entry also composes all fourteen Skills. Classic factions additionally retain all12 Nexus through the original Nexus or skill entry. Maker, later parent entry, nested shipment/rescue, phase-end industry and cleanup before mandatory winner Tech retain actual sources; held benefits never spill into a Homeworld or unrelated nested site. Native Homeworld/Nexus, unsupported E3 pairs/mixed families and unrelated overlays remain separate in this bounded profile, not outside the full goal.',
|
||||
'The fresh Leader Skills entry may explicitly add Discoveries with original classic or supported native decks, all fourteen skills, original Tech at three or more seats and Advanced Stronghold Cards. Supported profiles may retain Homeworlds without Nexus; classic factions may retain Homeworlds with Nexus. --nexus-cards retains all12 with the actual settled-alliance end-Spice closing deal after Great Maker and both Advanced piles. Existing native roster and Duke/assassination/Harkonnen restrictions remain. Actual hands/offers, physical entry, nested tariffs, stash capacity, typed rescue, native aftermath and printed invoices retain their sources. Native Homeworld/Nexus, unsupported E3 pairs/mixed families, independent variants and separate Banker/Mentat previews remain outside these bounded compositions.',
|
||||
'The same fresh entry also adds --nexus-cards --discoveries to paired Ixians+Tleilaxu, paired CHOAM+Richese or standalone Ecaz OR Moritani with classics: original all14 offered training, all12 Nexus and Discovery7/8, Basic/Advanced two through six, optional Tech at three or more seats and Advanced Strongholds. Original setup, first hands, offers and native components stay authoritative. Real Maker losses/votes/typed rides, both Advanced piles and settled alliance precede the end-Spice closing draw. E1 Suboid Cunning settles typed Suk rescue before one equal Cyborg Tank substitution; original cleanup and mandatory winner Tech precede a matching Face Dance. E2 keeps the signed two-marker frame and one invoice. Ecaz keeps quiet living/unclaimed Duke separate from training and a real owner-labelled mandatory Occupy; Moritani keeps skill-first assassination and one private Mentat replacement. Mixed/E3 pairs, Homeworlds, shared Duke, borrowed Smuggler/pair companions, native free returns, captured/foreign training, Basic odd Ecaz and Advanced Harkonnen stay separate.',
|
||||
'A separate fresh Nexus entry may explicitly add Discoveries to classic factions or both native Ixians/Tleilaxu or CHOAM/Richese with classics, one original family deck and no Skills. Only classic entries additionally retain Homeworlds. Original Tech requires three or more seats; Stronghold Cards require Advanced. End-Mentat ownership and next-turn physical entry remain. Great Maker finishes its losses, votes and typed reserve ride before ordinary Nexus settlement; cards are offered only at the end of the entire Spice Blow after both Advanced piles and settled alliance, not immediately or at Mentat. Source-clear return ledgers, Guild-priced nested shipment and signed native one-invoice marker pairs retain physical pieces and original capacities. Native Homeworld/Nexus, E3/mixed families, unrelated previews and unresolved mixed-module Fremen Betrayal stay separate from this no-Skills entry.',
|
||||
'Fresh Discovery retains Homeworlds in Basic/Advanced two through six classic or supported native E1/E2 seats, or standalone Ecaz OR Moritani with classic opponents, original seven cards/eight tokens and native reserve placement. Original Tech requires three or more seats; Stronghold Cards require Advanced. The separate original leader-skills --discoveries entry also composes all fourteen Skills. Classic or paired Ixians+Tleilaxu/CHOAM+Richese entries additionally retain all12 Nexus through the original Nexus or skill entry; standalone Ecaz OR Moritani composes Homeworld/Nexus with Skills. Maker, later parent entry, nested shipment/rescue, phase-end industry and cleanup before mandatory winner Tech retain actual sources; held benefits never spill into a Homeworld or unrelated nested site. Other native singles/mixed-deck Nexus, unsupported E3 pairs/mixed families and unrelated overlays remain separate in this bounded profile, not outside the full goal.',
|
||||
'The fresh Leader Skills entry may explicitly add Discoveries with original classic or supported native decks, all fourteen skills, original Tech at three or more seats and Advanced Stronghold Cards. Original Homeworlds may be retained; --nexus-cards requires classic factions, paired Ixians+Tleilaxu OR CHOAM+Richese with one family deck, or standalone Ecaz OR Moritani with classics. All12 Nexus retain the actual settled-alliance end-Spice closing deal after Great Maker and both Advanced piles. Existing native roster and Duke/assassination/Harkonnen restrictions remain. Actual hands/offers, physical entry, nested tariffs, stash capacity, typed rescue, native aftermath and printed invoices retain their sources. Other native singles/mixed decks, unsupported E3 pairs/mixed families, independent variants and separate Banker/Mentat previews remain outside these bounded compositions.',
|
||||
'The same fresh entry also adds --nexus-cards --discoveries to paired Ixians+Tleilaxu, paired CHOAM+Richese or standalone Ecaz OR Moritani with classics, optionally retaining original Homeworlds: all14 offered training, all12 Nexus and Discovery7/8, Basic/Advanced two through six, optional Tech at three or more seats and Advanced Strongholds. Original setup, first hands, offers and native components stay authoritative. Real Maker losses/votes/typed rides, both Advanced piles and settled alliance precede the end-Spice closing draw. E1 Suboid Cunning settles typed Suk rescue before one equal Cyborg Tank substitution; original cleanup and mandatory winner Tech precede a matching Face Dance, whose foreign-Homeworld suppression remains. E2 keeps the signed two-marker frame and one invoice. Ecaz keeps quiet living/unclaimed Duke separate from training and a real owner-labelled Arrakis Occupy; Moritani keeps skill-first assassination and one private Mentat replacement. Mixed/E3 pairs, shared Duke, borrowed Smuggler/pair companions, native free returns, captured/foreign training, Basic odd-force Ecaz and Advanced Harkonnen stay separate.',
|
||||
'A separate fresh Nexus entry may explicitly add Discoveries to classic factions or both native Ixians/Tleilaxu or CHOAM/Richese with classics, one original family deck and no Skills, optionally retaining original Homeworlds. Original Tech requires three or more seats; Stronghold Cards require Advanced. End-Mentat ownership and next-turn physical entry remain. Great Maker finishes its losses, votes and typed reserve ride before ordinary Nexus settlement; cards are offered only at the end of the entire Spice Blow after both Advanced piles and settled alliance, not immediately or at Mentat. Source-clear return ledgers, Guild-priced nested shipment and signed native one-invoice marker pairs retain physical pieces and original capacities. No-Skills E3/mixed families, unrelated previews and unresolved mixed-module Fremen Betrayal stay separate from this no-Skills entry.',
|
||||
'Original Stronghold Cards are claimed only at end-Mentat. A held benefit applies in its named territory or the mobile stronghold through its actual declared copy; revealed Discovery locations do not inherit another territory’s defense or bank support. Jacurutu counts toward stronghold victory but creates no seventh Stronghold Card. Physical free entry, nested tariffs, trained or native force casualties, card cleanup and optional Tech reward retain their original sources.',
|
||||
'Great Maker first resolves the ordinary worm consequences. All players then vote in storm order on creating a Nexus. A strict majority is required; a tie adds no Nexus and an existing Nexus in the phase stays recorded. Fremen may then ride with actual reserve forces to one legal destination for free, without using ordinary shipment or movement. Storm and occupancy restrictions still apply.',
|
||||
'Each of the six Discovery territory cards destroys prior spice and forces in the named blow territory, including Fremen forces, before placing six new spice subject to the storm. It also places a random available face-down token of the printed type at the card’s separate token destination.',
|
||||
@@ -4132,8 +4134,9 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
},
|
||||
{
|
||||
area: 'Verification', status: 'Partial',
|
||||
detail: 'Classic Homeworld Nexus optional modules add13 meaningful cases; affected242/242, types/lint/build pass. Twenty-two original six-seat games across15 families finish14233/14233 actions with375 JSON on one unchanged source tree. Human390px original typed invasion/zero-Suk and exact-three native return retain Kaitain14/Salusa5/visitor1; actual random Shrine entry, typed Maker ride and both-pile closing Moritani draw retain original armies. Native35-case and earlier source-qualified evidence remains in canonical logs. Native compositions, pending effects, full rules/combinations and deployed acceptance remain open.',
|
||||
evidence: ['tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', 'tests/homeworld-native-e1e2-modules-runtime.test.ts', 'tests/homeworld-native-e3-modules-runtime.test.ts', 'tests/homeworld-classic-discovery-runtime.test.ts', 'tests/homeworld-skills-discovery-runtime.test.ts', 'tests/homeworld-optional-modules-runtime.test.ts', 'tests/discovery-native-e1-skills-runtime.test.ts', 'tests/discovery-native-e2-skills-runtime.test.ts', 'tests/discovery-native-e3-skills-runtime.test.ts', 'tests/discovery-skills-strongholds-runtime.test.ts', 'tests/discovery-native-strongholds-runtime.test.ts', 'tests/ecaz-stronghold-runtime.test.ts', 'tests/e3-native-tech-runtime.test.ts', 'tests/discovery-classic-nexus-lifecycle.test.ts', 'tests/discovery-classic-nexus-effects.test.ts', 'tests/discovery-orgiz-advanced-runtime.test.ts', 'tests/discovery-classic-skills-runtime.test.ts', 'tests/discovery-module-profile.test.ts', 'tests/discovery-native-typed-runtime.test.ts', 'tests/discovery-native-marker-runtime.test.ts', 'tests/discovery-native-e3-runtime.test.ts', 'tests/discoveries.test.ts', 'tests/discovery-runtime.test.ts', 'tests/great-maker.test.ts', 'tests/discovery-board.test.ts', 'tests/discovery-admission.test.ts', 'tests/discovery-controls.test.ts', 'tests/discovery-entry.test.ts', 'tests/discovery-entry-runtime.test.ts', 'tests/discovery-entry-controls.test.ts', 'tests/discovery-entry-review.test.ts', 'tests/discovery-collection.test.ts', 'tests/discovery-flight.test.ts', 'tests/discovery-flight-engine.test.ts', 'tests/discovery-flight-recovery.test.ts', 'tests/discovery-battle.test.ts', 'tests/discovery-battle-recovery.test.ts', 'tests/discovery-storm.test.ts', 'tests/discovery-storm-recovery.test.ts', 'tests/discovery-shrine-engine.test.ts', 'tests/discovery-shrine-recovery.test.ts', 'docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md'],
|
||||
detail: 'Native Homeworld Nexus adds15 meaningful cases; affected307/307 across25 files. Across23 native families,59 of68 original six-seat games finish71137/71484 actions with1894 JSON on one unchanged source tree; nine captures preserve existing rulings. Actual390px original no-Skills Richese Cistern pair materializes8 counters from native reserves with one spice invoice; actual Ecaz Warmaster training, paid foreign-home entry and living-Duke Cunning retain original custody. Earlier classic/native source-qualified evidence remains in canonical logs. Other native families, pending effects, full rules/combinations and deployed acceptance remain open.',
|
||||
evidence: ['tests/homeworld-paired-nexus-runtime.test.ts', 'tests/homeworld-standalone-nexus-runtime.test.ts',
|
||||
'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', 'tests/homeworld-native-e1e2-modules-runtime.test.ts', 'tests/homeworld-native-e3-modules-runtime.test.ts', 'tests/homeworld-classic-discovery-runtime.test.ts', 'tests/homeworld-skills-discovery-runtime.test.ts', 'tests/homeworld-optional-modules-runtime.test.ts', 'tests/discovery-native-e1-skills-runtime.test.ts', 'tests/discovery-native-e2-skills-runtime.test.ts', 'tests/discovery-native-e3-skills-runtime.test.ts', 'tests/discovery-skills-strongholds-runtime.test.ts', 'tests/discovery-native-strongholds-runtime.test.ts', 'tests/ecaz-stronghold-runtime.test.ts', 'tests/e3-native-tech-runtime.test.ts', 'tests/discovery-classic-nexus-lifecycle.test.ts', 'tests/discovery-classic-nexus-effects.test.ts', 'tests/discovery-orgiz-advanced-runtime.test.ts', 'tests/discovery-classic-skills-runtime.test.ts', 'tests/discovery-module-profile.test.ts', 'tests/discovery-native-typed-runtime.test.ts', 'tests/discovery-native-marker-runtime.test.ts', 'tests/discovery-native-e3-runtime.test.ts', 'tests/discoveries.test.ts', 'tests/discovery-runtime.test.ts', 'tests/great-maker.test.ts', 'tests/discovery-board.test.ts', 'tests/discovery-admission.test.ts', 'tests/discovery-controls.test.ts', 'tests/discovery-entry.test.ts', 'tests/discovery-entry-runtime.test.ts', 'tests/discovery-entry-controls.test.ts', 'tests/discovery-entry-review.test.ts', 'tests/discovery-collection.test.ts', 'tests/discovery-flight.test.ts', 'tests/discovery-flight-engine.test.ts', 'tests/discovery-flight-recovery.test.ts', 'tests/discovery-battle.test.ts', 'tests/discovery-battle-recovery.test.ts', 'tests/discovery-storm.test.ts', 'tests/discovery-storm-recovery.test.ts', 'tests/discovery-shrine-engine.test.ts', 'tests/discovery-shrine-recovery.test.ts', 'docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md'],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeLeaderSkillsGameForAudit, initializePairedNexusGameForAudit,
|
||||
joinGame, newPlayer, viewGame, type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import { territory } from '../game/board';
|
||||
import { treacheryDeck, type Card } from '../game/cards';
|
||||
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
|
||||
import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { traitorDeck } from '../game/traitors';
|
||||
import { nativeShipmentSources } from '../game/homeworld-options';
|
||||
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
|
||||
import { DISCOVERY_CARD_PLACEMENTS } from '../game/discoveries';
|
||||
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
|
||||
import { greatMakerRideAction } from '../game/great-maker-options';
|
||||
import {
|
||||
nativeHomeworldArmy, nativeHomeworldClean, nativeHomeworldInventory, nativeHomeworldLeader,
|
||||
nativeHomeworldPlayer, nextNativeHomeworldModuleStep, frontNativeHomeworldSpice,
|
||||
} from './fixture-homeworld-native-e1e2-modules';
|
||||
import { nextDiscoveryPairedNexusE1SkillsStep } from './fixture-discovery-paired-nexus-e1-skills';
|
||||
import { nextDiscoveryPairedNexusE2SkillsStep } from './fixture-discovery-paired-nexus-e2-skills';
|
||||
import { nextPairedIxNexusModulesStep } from './fixture-paired-ix-nexus-modules';
|
||||
import { nextPairedChoamStep } from './fixture-paired-choam-nexus-modules';
|
||||
import { withClassicDiscoveryNexusLottery, withClassicDiscoveryNexusToken } from './fixture-discovery-classic-nexus';
|
||||
|
||||
export type HomeworldPairedNexusStep = { actor: string; action: Action };
|
||||
export type HomeworldPairedNexusOptions = {
|
||||
/** Original fresh lobby or original turn-one, unassigned setup. Saved deals are authoritative. */
|
||||
initial?: Game;
|
||||
family?: 'ix' | 'choam';
|
||||
advanced?: boolean;
|
||||
skills?: boolean;
|
||||
discovery?: boolean;
|
||||
tech?: boolean;
|
||||
strongholds?: boolean;
|
||||
/** Earn Arrakeen custody through a genuine first-turn three-marker reveal. */
|
||||
arrakeenClaim?: boolean;
|
||||
};
|
||||
export type HomeworldPairedNexusTransition = { before: Game; step: HomeworldPairedNexusStep; after: Game };
|
||||
export type HomeworldPairedNexusProgramme = {
|
||||
initial: Game; setup: Game; offered: Game | null; afterSetup: Game; game: Game;
|
||||
owner: string; native: string; guild: string; fremen: string; trainer: string | null;
|
||||
firstMarker: HomeworldPairedNexusTransition | null;
|
||||
entry: HomeworldPairedNexusTransition | null; vote: Game | null;
|
||||
ride: HomeworldPairedNexusTransition | null; alliance: Game; drawing: Game;
|
||||
draws: HomeworldPairedNexusTransition[]; actions: HomeworldPairedNexusStep[];
|
||||
};
|
||||
export const homeworldPairedNexusClean = nativeHomeworldClean;
|
||||
export const homeworldPairedNexusPlayer = nativeHomeworldPlayer;
|
||||
export const homeworldPairedNexusArmy = nativeHomeworldArmy;
|
||||
|
||||
/** Conservation covers the original caches as well as ordinary cards, every
|
||||
* Nexus singleton, trained discs and native/visitor physical counters. */
|
||||
export function homeworldPairedNexusInventory(game: Game): void {
|
||||
nativeHomeworldInventory(game);
|
||||
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
|
||||
...(game.ixSetupCards ?? []), ...(game.auction?.cards.slice(game.auction.index) ?? []),
|
||||
...(game.richeseCache ?? []), ...(game.richeseRemoved ?? [])];
|
||||
assert.deepEqual(cards.map(c => c.id).sort(),
|
||||
[...treacheryDeck(game.expansions), ...(game.players.some(p => p.faction === 'richese') ? richeseCards() : [])]
|
||||
.map(c => c.id).sort());
|
||||
validateNexusCards(game.nexusCards!.cards!, game.players);
|
||||
const nx = game.nexusCards!.cards!;
|
||||
assert.deepEqual([...nx.deck, ...nx.discard, ...Object.values(nx.hands).filter(c => c !== null)].sort(), [...NEXUS_FACTIONS].sort());
|
||||
}
|
||||
function shuffleRolls(source: readonly string[], desired: readonly string[]): number[] {
|
||||
assert.deepEqual([...source].sort(), [...desired].sort());
|
||||
const working = [...source], rolls: number[] = [];
|
||||
for (let i = working.length - 1; i > 0; i--) {
|
||||
const j = working.indexOf(desired[i]); assert.ok(j >= 0 && j <= i);
|
||||
rolls.push(Math.floor((j + 0.5) / (i + 1) * 0x100000000));
|
||||
[working[i], working[j]] = [working[j], working[i]];
|
||||
}
|
||||
return rolls;
|
||||
}
|
||||
/** Only word-sized original undealt lotteries are selected. UUID entropy is native. */
|
||||
function lottery<T>(rolls: readonly number[], operation: () => T): T {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
const native = crypto.getRandomValues.bind(crypto); let cursor = 0;
|
||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||
if (array instanceof Uint32Array && array.length === 1) array[0] = rolls[cursor++] ?? 0xffffffff;
|
||||
else Reflect.apply(native, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try { return operation(); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
|
||||
/** Original producers retain response priority. Owned Suk, substitution, cards,
|
||||
* Tech and Face Dance are explicit endpoints; no default replaces a human plan. */
|
||||
export function nextHomeworldPairedNexusStep(game: Game): HomeworldPairedNexusStep | null {
|
||||
if (homeworldPairedNexusClean(game) && game.nexusCards?.phase?.stage === 'drawing') {
|
||||
const phase = game.nexusCards.phase, actor = phase.eligible.find(id => !phase.done.includes(id)); assert.ok(actor);
|
||||
return { actor, action: { type: 'nexusCardChoice', turn: game.turn,
|
||||
card: game.nexusCards.cards!.hands[actor] ?? null, choice: 'keep', ownRedraws: 0 } };
|
||||
}
|
||||
if (!game.response && !game.phaseOpening && game.decision) {
|
||||
const d = game.decision;
|
||||
if (['sukRescue', 'ixSubstitution', 'battleCards', 'faceDance', 'strongholdCopy'].includes(d.kind) ||
|
||||
d.kind === 'techToken' && !!game.battle) return null;
|
||||
if (d.kind === 'techToken') return { actor: d.player, action: { type: 'decision', token: d.choices[0] } };
|
||||
if (d.kind === 'richeseUnbid')
|
||||
return { actor: d.player, action: { type: 'decision', event: game.richeseBidding!.event, keep: false } };
|
||||
// Both physical and ordinary Guild arrival windows must allow the declared programme.
|
||||
if (d.kind === 'guildShipment' || d.kind === 'homeworldShipmentGuild')
|
||||
return { actor: d.player, action: { type: 'decision', allow: true,
|
||||
...(d.kind === 'homeworldShipmentGuild' ? { event: d.event } : {}) } };
|
||||
if (d.kind === 'battleLosses') {
|
||||
const maximum = Math.max(...d.options.map(o => o.elite));
|
||||
return { actor: d.player, action: { type: 'decision', choice: d.options.findIndex(o => o.elite === maximum) } };
|
||||
}
|
||||
}
|
||||
if (game.discoveries) return game.expansions[0] === 'ix'
|
||||
? nextDiscoveryPairedNexusE1SkillsStep(game) : nextDiscoveryPairedNexusE2SkillsStep(game);
|
||||
if (game.leaderSkills) return nextNativeHomeworldModuleStep(game);
|
||||
return game.expansions[0] === 'ix' ? nextPairedIxNexusModulesStep(game) : nextPairedChoamStep(game);
|
||||
}
|
||||
export function stepHomeworldPairedNexus(game: Game, step: HomeworldPairedNexusStep,
|
||||
actions?: HomeworldPairedNexusStep[]): Game {
|
||||
actions?.push(structuredClone(step));
|
||||
return withClassicDiscoveryNexusLottery(() => applyAction(game, step.actor, step.action));
|
||||
}
|
||||
export function advanceHomeworldPairedNexus(state: Game, until: (game: Game) => boolean,
|
||||
actions?: HomeworldPairedNexusStep[]): Game {
|
||||
let game = structuredClone(state);
|
||||
for (let n = 0; n < 3000; n++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const step = nextHomeworldPairedNexusStep(game);
|
||||
assert.ok(step, `Original paired Homeworld programme needs its owned ${game.decision?.kind ?? 'private plan'} at ${game.turn}/${game.phase}`);
|
||||
game = stepHomeworldPairedNexus(game, step, actions);
|
||||
}
|
||||
throw Error('Original paired Homeworld/Nexus programme did not reach its selected boundary.');
|
||||
}
|
||||
export function settleHomeworldPairedNexus(game: Game, actions?: HomeworldPairedNexusStep[]): Game {
|
||||
return advanceHomeworldPairedNexus(game, g => homeworldPairedNexusClean(g) &&
|
||||
!g.pendingShipment && !g.pendingHomeworldShipment && !g.pendingTreacheryDiscard && !g.pendingChoamWorthless, actions);
|
||||
}
|
||||
function transition(game: Game, step: HomeworldPairedNexusStep, actions: HomeworldPairedNexusStep[]): HomeworldPairedNexusTransition {
|
||||
const before = structuredClone(game), after = stepHomeworldPairedNexus(game, step, actions);
|
||||
return { before, step: structuredClone(step), after };
|
||||
}
|
||||
|
||||
/** One initializer path for both human fresh lobbies and conserved original
|
||||
* unassigned setup. This is offline original play, not authenticated HTTP proof. */
|
||||
export function initializeHomeworldPairedNexus(options: HomeworldPairedNexusOptions = {}) {
|
||||
const family = options.family ?? (options.initial?.expansions[0] === 'choam' ? 'choam' : 'ix');
|
||||
const advanced = options.advanced ?? options.initial?.advanced ?? true;
|
||||
const skills = options.skills ?? (options.initial?.status === 'setup' ? !!options.initial.leaderSkills : true);
|
||||
const discovery = options.discovery ?? (options.initial ? options.initial.discoveryEnabled === true : false);
|
||||
const tech = options.tech ?? (options.initial ? !!options.initial.techTokens : true);
|
||||
const strongholds = options.strongholds ?? (options.initial ? !!options.initial.strongholdCards : advanced);
|
||||
const roster = family === 'ix' ? ['ixians', 'tleilaxu', 'guild', 'fremen'] as const
|
||||
: ['richese', 'choam', 'guild', 'fremen'] as const;
|
||||
let game = options.initial ? structuredClone(options.initial)
|
||||
: createGame('HOMEWORLDPAIREDNEXUS', newPlayer(roster[0], roster[0], roster[0]), advanced, [family]);
|
||||
if (!options.initial) for (const faction of roster.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
|
||||
assert.deepEqual(game.expansions, [family]); assert.equal(game.advanced, advanced);
|
||||
assert.deepEqual(game.players.map(p => p.faction).sort(), [...roster].sort());
|
||||
assert.ok(game.status === 'lobby' || game.status === 'setup' && game.turn === 1 && game.phase === 0 &&
|
||||
(!game.leaderSkills || game.leaderSkills.assignments.length === 0) && game.players.every(p =>
|
||||
!p.traitors.length && !p.faceDancers?.length && p.leaders.every(l => !l.dead && !l.usedAt)),
|
||||
'Continue only an original lobby or its original unassigned first-turn setup.');
|
||||
const initial = structuredClone(game), actions: HomeworldPairedNexusStep[] = [];
|
||||
const seat = (faction: string) => game.players.find(p => p.faction === faction)!.id;
|
||||
const owner = seat(roster[0]), native = seat(roster[1]), guild = seat('guild'), fremen = seat('fremen');
|
||||
const selected: LeaderSkillId[] = ['suk-graduate', 'prana-bindu-adept', 'swordmaster-of-ginaz', 'warmaster'];
|
||||
if (game.status === 'lobby') {
|
||||
for (const [type, enabled, present] of [
|
||||
['homeworlds', true, !!game.homeworlds], ['techTokens', tech, !!game.techTokens],
|
||||
['strongholdCards', strongholds, !!game.strongholdCards],
|
||||
] as const) if (enabled !== present)
|
||||
game = stepHomeworldPairedNexus(game, { actor: game.host, action: { type, enabled } }, actions);
|
||||
game.discoveryEnabled = discovery; game.nexusCards ??= { cards: null, phase: null };
|
||||
for (const p of game.players) if (!p.ready)
|
||||
game = stepHomeworldPairedNexus(game, { actor: p.id, action: { type: 'ready' } }, actions);
|
||||
const skillsSource = LEADER_SKILL_CARDS.map(c => c.id), rest = skillsSource.filter(c => !selected.includes(c));
|
||||
const skillsOrder = selected.flatMap(c => [c, rest.shift()!]).concat(rest);
|
||||
const deck = treacheryDeck(game.expansions), remaining = [...deck];
|
||||
const kinds: Card['kind'][] = family === 'ix' ? ['worthless', 'worthless', 'worthless', 'worthless']
|
||||
: ['worthless', 'special', 'worthless', 'worthless'];
|
||||
const prefix = kinds.map(kind => {
|
||||
const i = remaining.findIndex(c => c.kind === kind && (kind !== 'special' || c.effect !== 'karama')); assert.ok(i >= 0);
|
||||
return remaining.splice(i, 1)[0];
|
||||
});
|
||||
const rolls = [...(skills ? shuffleRolls(skillsSource, skillsOrder) : []),
|
||||
...(discovery ? Array.from({ length: 7 }, () => 0xffffffff) : []),
|
||||
...Array.from({ length: NEXUS_FACTIONS.length - 1 }, () => 0xffffffff),
|
||||
...shuffleRolls(deck.map(c => c.id), [...prefix, ...remaining].map(c => c.id))];
|
||||
game = lottery(rolls, () => skills ? initializeLeaderSkillsGameForAudit(game) : initializePairedNexusGameForAudit(game));
|
||||
}
|
||||
assert.equal(!!game.leaderSkills, skills); assert.equal(!!game.discoveries, discovery);
|
||||
assert.ok(game.homeworlds && game.nexusCards?.cards);
|
||||
assert.equal(!!game.techTokens, tech); assert.equal(!!game.strongholdCards, strongholds);
|
||||
const setup = structuredClone(game); let offered: Game | null = null, trainer: string | null = null;
|
||||
for (let n = 0; game.status === 'setup' && n < 250; n++) {
|
||||
let step: HomeworldPairedNexusStep;
|
||||
if (game.setupStage === 'leaderSkills') {
|
||||
offered ??= structuredClone(game);
|
||||
const actor = Object.keys(game.leaderSkills!.offers)[0], view = viewGame(game, actor).leaderSkills!;
|
||||
const wanted = selected[game.players.findIndex(p => p.id === actor)];
|
||||
const skill = options.initial?.status === 'setup' ? view.offer!.cards.find(c => !view.unavailableSkills?.[c])
|
||||
: view.offer!.cards.find(c => c === wanted && !view.unavailableSkills?.[c]);
|
||||
const eligible = new Set(view.eligibleLeaders.map(l => l.id));
|
||||
const leader = nativeHomeworldPlayer(game, actor).leaders.filter(l => eligible.has(l.id)).sort((a, b) => b.strength - a.strength)[0];
|
||||
assert.ok(skill && leader); if (actor === owner) trainer = leader.id;
|
||||
step = { actor, action: { type: 'leaderSkill', event: view.offer!.event, skill, leader: leader.id } };
|
||||
} else if (game.decision?.kind === 'ixSetup') {
|
||||
const actor = game.decision.player, card = viewGame(game, actor).ixTechnology!.setup!.find(c => c.kind === 'worthless'); assert.ok(card);
|
||||
step = { actor, action: { type: 'decision', card: card.id } };
|
||||
} else {
|
||||
const next = nextHomeworldPairedNexusStep(game); assert.ok(next); step = next;
|
||||
if (step.action.type === 'traitor' && trainer) {
|
||||
const choices = nativeHomeworldPlayer(game, step.actor).traitorChoices;
|
||||
step.action.leader = choices.find(id => id !== trainer) ?? choices[0];
|
||||
}
|
||||
}
|
||||
// A legal original Fremen choice avoids an incidental third allied
|
||||
// stronghold at END Mentat; it never relocates already placed counters.
|
||||
if (step.action.type === 'fremenSetup') step.action.placements = {
|
||||
sietch_tabr: 0, false_wall_south: 10, false_wall_west: 0,
|
||||
};
|
||||
if (family === 'ix' && game.setupStage === 'forces' && trainer) {
|
||||
const held = new Set(game.players.flatMap(p => p.traitors));
|
||||
const source = traitorDeck(game.players, true).filter(id => !held.has(id)); assert.ok(source.includes(trainer));
|
||||
actions.push(structuredClone(step));
|
||||
game = lottery(shuffleRolls(source, [trainer, ...source.filter(id => id !== trainer)]), () => applyAction(game, step.actor, step.action));
|
||||
} else game = stepHomeworldPairedNexus(game, step, actions);
|
||||
}
|
||||
assert.equal(game.status, 'playing'); homeworldPairedNexusInventory(game);
|
||||
return { initial, setup, offered, afterSetup: structuredClone(game), game, owner, native, guild, fremen, trainer, actions };
|
||||
}
|
||||
|
||||
/** Turn one is played, not repositioned. Turn two's actual worm or Maker opens
|
||||
* an alliance, then actual unallied closing draws deal the matching singletons. */
|
||||
export function createHomeworldPairedNexusProgramme(options: HomeworldPairedNexusOptions = {}): HomeworldPairedNexusProgramme {
|
||||
const s = initializeHomeworldPairedNexus(options), actions = s.actions;
|
||||
let game = s.game;
|
||||
const discovery = !!game.discoveries, family = game.expansions[0];
|
||||
frontNativeHomeworldSpice(game, c => 'territory' in c && (discovery ? c.discovery === 'discovery-hagga-basin' : !c.discovery && c.territory === 'hagga_basin'), 0);
|
||||
if (game.advanced) frontNativeHomeworldSpice(game, c => 'territory' in c && !c.discovery && c.territory === 'broken_land', 1);
|
||||
const second = game.advanced ? 2 : 1;
|
||||
frontNativeHomeworldSpice(game, c => 'worm' in c && (discovery ? !!c.greatMaker : !c.greatMaker && !c.suppressed), second);
|
||||
frontNativeHomeworldSpice(game, c => 'territory' in c && !c.discovery && c.territory === 'rock_outcroppings', second + 1);
|
||||
if (game.advanced) frontNativeHomeworldSpice(game, c => 'territory' in c && !c.discovery && c.territory === 'oh_gap', second + 2);
|
||||
game = advanceHomeworldPairedNexus(game, g => g.phase === 1 && homeworldPairedNexusClean(g), actions);
|
||||
if (discovery) {
|
||||
for (let n = 0; !game.discoveries!.tokens.some(t => t.face === 'shrine' && t.status === 'placed') && n < 100; n++) {
|
||||
const step = nextHomeworldPairedNexusStep(game); assert.ok(step); actions.push(structuredClone(step));
|
||||
game = withClassicDiscoveryNexusToken(game, 'shrine', () => applyAction(game, step.actor, step.action));
|
||||
}
|
||||
assert.ok(game.discoveries!.tokens.some(t => t.face === 'shrine' && t.status === 'placed'));
|
||||
}
|
||||
let firstMarker: HomeworldPairedNexusTransition | null = null;
|
||||
const collector = family === 'ix' ? s.owner : s.guild;
|
||||
const needed = new Set(discovery ? [collector, ...(family === 'choam' ? [s.owner] : [])] : family === 'choam' ? [s.owner] : []);
|
||||
while (needed.size) {
|
||||
game = advanceHomeworldPairedNexus(game, g => g.turn === 1 && g.phase === 5 && homeworldPairedNexusClean(g) && needed.has(g.active!), actions);
|
||||
const actor = game.active!;
|
||||
if (family === 'choam' && actor === s.owner) {
|
||||
const site = options.arrakeenClaim ? 'arrakeen' : 'habbanya_ridge_sietch';
|
||||
const p = nativeHomeworldPlayer(game, actor), token = p.noField!.tokens.find(t => t.value === (options.arrakeenClaim ? 3 : 0))!;
|
||||
firstMarker = transition(game, { actor, action: { type: 'ship', territory: site,
|
||||
sector: territory(site).sectors[0], noField: token.id, event: p.noFieldEvent, allyPayment: 0 } }, actions);
|
||||
game = settleHomeworldPairedNexus(firstMarker.after, actions); firstMarker.after = structuredClone(game);
|
||||
game = stepHomeworldPairedNexus(game, { actor, action: { type: 'revealNoField', token: token.id,
|
||||
event: nativeHomeworldPlayer(game, actor).noFieldEvent } }, actions);
|
||||
} else {
|
||||
const placement = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'], elite = family === 'ix' ? 1 : 0;
|
||||
const sources = nativeShipmentSources(viewGame(game, actor), 3, elite); assert.ok(sources);
|
||||
game = settleHomeworldPairedNexus(stepHomeworldPairedNexus(game, { actor, action: { type: 'ship', territory: placement.territory,
|
||||
sector: placement.sector, amount: 3, elite, allyPayment: 0, homeworldSources: sources } }, actions), actions);
|
||||
}
|
||||
needed.delete(actor);
|
||||
if (needed.size) game = stepHomeworldPairedNexus(game, { actor, action: { type: 'endMovement' } }, actions);
|
||||
}
|
||||
if (discovery) {
|
||||
game = advanceHomeworldPairedNexus(game, g => g.phase === 7 && homeworldPairedNexusClean(g), actions);
|
||||
const token = game.discoveries!.tokens.find(t => t.face === 'shrine' && t.status === 'placed')!;
|
||||
for (const reveal of [false, true]) game = stepHomeworldPairedNexus(game,
|
||||
{ actor: collector, action: { type: 'discovery', token: token.id, reveal } }, actions);
|
||||
}
|
||||
game = advanceHomeworldPairedNexus(game, g => g.turn === 2, actions);
|
||||
let entry: HomeworldPairedNexusTransition | null = null, vote: Game | null = null, ride: HomeworldPairedNexusTransition | null = null;
|
||||
if (discovery) {
|
||||
game = advanceHomeworldPairedNexus(game, g => g.decision?.kind === 'discoveryEntry' && g.decision.player === collector, actions);
|
||||
const view = viewGame(game, collector), offered = view.discoveryEntry!.sources;
|
||||
const sources = family === 'ix' ? offered.map(source => ({ ...source, normal: Math.min(source.normal, 1), elite: Math.min(source.elite, 1) })) : offered;
|
||||
const action = discoveryEntryMoveAction(view, sources); assert.ok(action);
|
||||
entry = transition(game, { actor: collector, action }, actions); game = entry.after;
|
||||
game = advanceHomeworldPairedNexus(game, g => g.decision?.kind === 'greatMakerVote', actions); vote = structuredClone(game);
|
||||
while (game.decision?.kind === 'greatMakerVote') game = stepHomeworldPairedNexus(game,
|
||||
{ actor: game.decision.player, action: { type: 'decision', event: game.decision.event, yes: true } }, actions);
|
||||
assert.ok(game.decision?.kind === 'greatMakerRide' && game.decision.player === s.fremen);
|
||||
const actionRide = greatMakerRideAction(viewGame(game, s.fremen), 'polar_sink', 0, 2, game.advanced ? 1 : 0); assert.ok(actionRide);
|
||||
ride = transition(game, { actor: s.fremen, action: actionRide }, actions); game = ride.after;
|
||||
}
|
||||
game = advanceHomeworldPairedNexus(game, g => !!g.nexus && !g.spiceWindow && !g.spiceResolution && homeworldPairedNexusClean(g), actions);
|
||||
for (const [actor, target] of [[s.guild, s.fremen], [s.fremen, s.guild]])
|
||||
game = stepHomeworldPairedNexus(game, { actor, action: { type: 'alliance', target } }, actions);
|
||||
const alliance = structuredClone(game);
|
||||
game = advanceHomeworldPairedNexus(game, g => g.nexusCards?.phase?.stage === 'drawing' && homeworldPairedNexusClean(g), actions);
|
||||
const drawing = structuredClone(game), draws: HomeworldPairedNexusTransition[] = [];
|
||||
for (const actor of [s.owner, s.native]) {
|
||||
const cards = game.nexusCards!.cards!, face = nativeHomeworldPlayer(game, actor).faction;
|
||||
const at = cards.deck.indexOf(face); assert.ok(at >= 0); cards.deck.unshift(cards.deck.splice(at, 1)[0]);
|
||||
const draw = transition(game, { actor, action: { type: 'nexusCardChoice', turn: game.turn,
|
||||
card: cards.hands[actor] ?? null, choice: 'draw', ownRedraws: 0 } }, actions);
|
||||
draws.push(draw); game = draw.after;
|
||||
}
|
||||
game = advanceHomeworldPairedNexus(game, g => g.phase === 5 && homeworldPairedNexusClean(g), actions);
|
||||
homeworldPairedNexusInventory(game);
|
||||
return { ...s, game, firstMarker, entry, vote, ride, alliance, drawing, draws, actions };
|
||||
}
|
||||
|
||||
/** Legal native sources, actual Guild allowances and custody settlement. */
|
||||
export function shipHomeworldPairedNexus(game: Game, actor: string, destination: string, amount: number, elite = 0,
|
||||
actions?: HomeworldPairedNexusStep[]): Game {
|
||||
const sources = nativeShipmentSources(viewGame(game, actor), amount, elite); assert.ok(sources);
|
||||
let action: Action;
|
||||
if (destination.startsWith('homeworld:')) {
|
||||
const choice = homeworldShipmentChoice(viewGame(game, actor), destination,
|
||||
Object.fromEntries(Object.entries(sources).filter(([, g]) => g.normal + g.elite > 0)));
|
||||
assert.ok(choice.action, choice.blocked ?? undefined); action = choice.action;
|
||||
} else action = { type: 'ship', territory: destination, sector: destination === 'shrine' ? 0 : territory(destination).sectors[0],
|
||||
amount, elite, allyPayment: 0, homeworldSources: sources };
|
||||
return settleHomeworldPairedNexus(stepHomeworldPairedNexus(game, { actor, action }, actions), actions);
|
||||
}
|
||||
export function openHomeworldPairedNexusBattle(game: Game, owner: string, opponent: string, location: string,
|
||||
hidden = true, actions?: HomeworldPairedNexusStep[]): Game {
|
||||
game = advanceHomeworldPairedNexus(game, g => g.phase === 6 && homeworldPairedNexusClean(g) && !!g.active, actions);
|
||||
assert.ok(game.active === owner || game.active === opponent);
|
||||
game = stepHomeworldPairedNexus(game, { actor: game.active!, action: { type: 'chooseBattle', territory: location,
|
||||
target: game.active === owner ? opponent : owner } }, actions);
|
||||
for (let n = 0; n < 200; n++) {
|
||||
if (homeworldPairedNexusClean(game) && game.battle && !game.battle.preparation && game.battle.preLeader?.closed !== false) return game;
|
||||
const next = nextHomeworldPairedNexusStep(game); assert.ok(next);
|
||||
if (next.action.type === 'leaderSkillVisibility') next.action.hide = next.actor === owner && hidden;
|
||||
game = stepHomeworldPairedNexus(game, next, actions);
|
||||
}
|
||||
throw Error('Original Homeworld battle did not reach its sealed-plan boundary.');
|
||||
}
|
||||
export function revealHomeworldPairedNexusPlans(game: Game, plans: readonly HomeworldPairedNexusStep[],
|
||||
actions?: HomeworldPairedNexusStep[]): Game {
|
||||
for (const plan of plans) {
|
||||
game = stepHomeworldPairedNexus(game, plan, actions);
|
||||
while (game.response || game.phaseOpening || game.decision?.kind === 'fullPlanOffer') {
|
||||
const next = nextHomeworldPairedNexusStep(game); assert.ok(next); game = stepHomeworldPairedNexus(game, next, actions);
|
||||
}
|
||||
}
|
||||
assert.ok(game.battle?.revealed); return game;
|
||||
}
|
||||
/** Owned cleanup is explicit, and reward/Face Dance order remains engine-owned. */
|
||||
export function finishHomeworldPairedNexusBattle(game: Game, actions?: HomeworldPairedNexusStep[]): Game {
|
||||
for (let n = 0; n < 600; n++) {
|
||||
if (!game.battle && homeworldPairedNexusClean(game) && !game.pendingTreacheryDiscard) return game;
|
||||
let next = nextHomeworldPairedNexusStep(game);
|
||||
if (!next && !game.response && !game.phaseOpening) {
|
||||
const d = game.decision; assert.ok(d);
|
||||
if (d.kind === 'battleCards') next = { actor: d.player, action: { type: 'decision', discard: [] } };
|
||||
else if (d.kind === 'techToken') next = { actor: d.player, action: { type: 'decision', token: d.choices[0] } };
|
||||
else if (d.kind === 'faceDance') next = { actor: d.player, action: { type: 'decision', reveal: false } };
|
||||
else throw Error(`Choose the physical ${d.kind} before continuing cleanup.`);
|
||||
}
|
||||
assert.ok(next); game = stepHomeworldPairedNexus(game, next, actions);
|
||||
}
|
||||
throw Error('Original paired Homeworld aftermath did not finish.');
|
||||
}
|
||||
export const homeworldPairedNexusLeader = nativeHomeworldLeader;
|
||||
@@ -0,0 +1,414 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, viewGame,
|
||||
type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import { leaders, treacheryDeck } from '../game/cards';
|
||||
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
|
||||
import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards';
|
||||
import { validateLeaderSkills } from '../game/leader-skills';
|
||||
import { validateDiscoveryState, DISCOVERY_CARD_PLACEMENTS } from '../game/discoveries';
|
||||
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
|
||||
import { nativeShipmentSources } from '../game/homeworld-options';
|
||||
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
|
||||
import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
|
||||
import { homeworldForceGroups } from '../game/homeworld-custody';
|
||||
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
|
||||
import { traitorDeck } from '../game/traitors';
|
||||
import { validateAmbassadors } from '../game/ecaz-ambassadors';
|
||||
import {
|
||||
nativeE3HomeworldPlayer as standaloneHomeworldNexusPlayer,
|
||||
nativeE3HomeworldPool as standaloneHomeworldNexusPool,
|
||||
nativeE3HomeworldPolicy as standaloneHomeworldNexusPolicy,
|
||||
type NativeE3HomeworldOptions,
|
||||
} from './fixture-homeworld-native-e3-modules';
|
||||
import {
|
||||
completeDiscoveryStandaloneNexusE3SkillsSetup, nextDiscoveryStandaloneNexusE3SkillsStep,
|
||||
type DiscoveryStandaloneNexusE3SkillsBoundary,
|
||||
} from './fixture-discovery-standalone-nexus-e3-skills';
|
||||
import { ecazNexusSkillsModulesCunningRequest } from './fixture-ecaz-nexus-skills-modules';
|
||||
import { withClassicDiscoveryNexusLottery, withClassicDiscoveryNexusToken } from './fixture-discovery-classic-nexus';
|
||||
|
||||
export { standaloneHomeworldNexusPlayer, standaloneHomeworldNexusPool, standaloneHomeworldNexusPolicy };
|
||||
export type StandaloneHomeworldNexusOptions = Omit<NativeE3HomeworldOptions, 'skills' | 'assassination' | 'lethal'>;
|
||||
export type StandaloneHomeworldNexusStep = { actor: string; action: Action };
|
||||
export type StandaloneHomeworldNexusTransition = { before: Game; step: StandaloneHomeworldNexusStep; after: Game };
|
||||
export type StandaloneHomeworldNexusSetup = {
|
||||
initial: Game; offered: Game; afterSetup: Game; owner: string; opponent: string; trainer: string;
|
||||
actions: StandaloneHomeworldNexusStep[];
|
||||
};
|
||||
export type StandaloneHomeworldNexusClosing = StandaloneHomeworldNexusSetup & {
|
||||
firstStorm: Game; firstMentat: StandaloneHomeworldNexusTransition; alliance: Game;
|
||||
draw: StandaloneHomeworldNexusTransition; game: Game;
|
||||
};
|
||||
export type StandaloneHomeworldNexusBattle = {
|
||||
arrival: StandaloneHomeworldNexusTransition; settledArrival: Game; beforeBattle: Game;
|
||||
game: Game; plans: StandaloneHomeworldNexusStep[]; location: string;
|
||||
};
|
||||
export const standaloneHomeworldNexusClean = (g: Game) =>
|
||||
!g.response && !g.phaseOpening && !g.decision && !g.pendingTreacheryDiscard && !g.truthtrance;
|
||||
export const reloadStandaloneHomeworldNexus = (g: Game): Game => JSON.parse(JSON.stringify(g)) as Game;
|
||||
|
||||
/** The programme owns no alternate profile or producer. This is the same private
|
||||
* undealt module declaration and host actions used by the original HW fixtures.
|
||||
* Supplied seat IDs, already-ready seats and genuinely dealt setup offers survive. */
|
||||
export function createStandaloneHomeworldNexusLobby(options: StandaloneHomeworldNexusOptions = {}): Game {
|
||||
const faction = options.faction ?? options.initial?.players.find(p => ['ecaz', 'moritani'].includes(p.faction))?.faction ?? 'ecaz';
|
||||
const advanced = options.advanced ?? options.initial?.advanced ?? true;
|
||||
const roster = options.roster ?? [faction, 'guild', 'emperor'] as FactionId[];
|
||||
let g = options.initial ? reloadStandaloneHomeworldNexus(options.initial)
|
||||
: createGame('STANDALONEHOMENEXUS', newPlayer(roster[0], roster[0], roster[0]), advanced, ['ecaz']);
|
||||
if (!options.initial) for (const f of roster.slice(1)) joinGame(g, newPlayer(f, f, f));
|
||||
assert.equal(g.status, 'lobby'); assert.equal(g.advanced, advanced);
|
||||
assert.deepEqual(g.expansions, ['ecaz']);
|
||||
assert.equal(g.players.filter(p => p.faction === 'ecaz' || p.faction === 'moritani').length, 1);
|
||||
assert.equal(new Set(g.players.map(p => p.faction)).size, g.players.length);
|
||||
assert.ok(g.players.every(p => p.faction === faction || ['guild', 'emperor', 'atreides', 'fremen', 'beneGesserit',
|
||||
...(!advanced ? ['harkonnen'] : [])].includes(p.faction)));
|
||||
const tech = options.tech ?? !!g.techTokens, strongholds = options.strongholds ?? !!g.strongholdCards;
|
||||
assert.ok(g.players.length >= (tech ? 3 : 2) && g.players.length <= 6);
|
||||
assert.ok(!strongholds || advanced); assert.ok(!g.leaderSkills && !g.ecazTreachery);
|
||||
if (!g.homeworlds) g = applyAction(g, g.host, { type: 'homeworlds', enabled: true });
|
||||
if (!!g.techTokens !== tech) g = applyAction(g, g.host, { type: 'techTokens', enabled: tech });
|
||||
if (!!g.strongholdCards !== strongholds) g = applyAction(g, g.host, { type: 'strongholdCards', enabled: strongholds });
|
||||
g.discoveryEnabled = options.discovery ?? !!g.discoveryEnabled;
|
||||
g.nexusCards ??= { cards: null, phase: null };
|
||||
assert.ok(g.homeworlds?.custody === null && g.nexusCards.cards === null && g.nexusCards.phase === null);
|
||||
return g;
|
||||
}
|
||||
|
||||
/** Select only an original, not-yet-dealt shuffle. UUID entropy is untouched. */
|
||||
function permutation(source: readonly string[], desired: readonly string[]): number[] {
|
||||
const working = [...source], rolls: number[] = [];
|
||||
assert.deepEqual([...source].sort(), [...desired].sort());
|
||||
for (let i = working.length - 1; i > 0; i--) {
|
||||
const j = working.indexOf(desired[i]); assert.ok(j >= 0 && j <= i);
|
||||
rolls.push(Math.floor((j + 0.5) * 0x100000000 / (i + 1)));
|
||||
[working[i], working[j]] = [working[j], working[i]];
|
||||
}
|
||||
return rolls;
|
||||
}
|
||||
function lottery<T>(rolls: readonly number[], operation: () => T): T {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
const native = crypto.getRandomValues.bind(crypto); let cursor = 0;
|
||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||
if (array instanceof Uint32Array && array.length === 1 && cursor < rolls.length) array[0] = rolls[cursor++];
|
||||
else Reflect.apply(native, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try { return operation(); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
export function stepStandaloneHomeworldNexus(g: Game, step: StandaloneHomeworldNexusStep,
|
||||
actions?: StandaloneHomeworldNexusStep[]): Game {
|
||||
actions?.push(structuredClone(step));
|
||||
// Actual JSON save continuation is the changed-path programme, not an input-copy assertion.
|
||||
return withClassicDiscoveryNexusLottery(() => applyAction(reloadStandaloneHomeworldNexus(g), step.actor, step.action));
|
||||
}
|
||||
export function nextStandaloneHomeworldNexusStep(g: Game): StandaloneHomeworldNexusStep | null {
|
||||
if (!g.response && !g.phaseOpening && !g.pendingTreacheryDiscard) {
|
||||
const d = g.decision;
|
||||
if (d?.kind === 'guildShipment') return { actor: d.player, action: { type: 'decision', allow: true } };
|
||||
if (d?.kind === 'homeworldShipmentGuild') return { actor: d.player, action: { type: 'decision', event: d.event, allow: true } };
|
||||
if (d?.kind === 'homeworldDefense') return { actor: d.player, action: { type: 'decision', event: d.event, use: false } };
|
||||
if (d?.kind === 'homeworldRevivalDeployment') return { actor: d.player, action: { type: 'decision', event: d.event, decline: true } };
|
||||
if (d && ['sukRescue', 'moritaniAssassinate', 'techToken', 'ecazBattleLead'].includes(d.kind)) return null;
|
||||
}
|
||||
const next = nextDiscoveryStandaloneNexusE3SkillsStep(g);
|
||||
if (next?.action.type === 'stormDial') next.action.amount = g.turn === 1 ? 0 : 1;
|
||||
return next;
|
||||
}
|
||||
export function advanceStandaloneHomeworldNexus(g: Game, until: (g: Game) => boolean,
|
||||
actions?: StandaloneHomeworldNexusStep[]): Game {
|
||||
for (let n = 0; n < 3000; n++) {
|
||||
if (until(g)) return g;
|
||||
assert.notEqual(g.status, 'finished');
|
||||
const next = nextStandaloneHomeworldNexusStep(g);
|
||||
assert.ok(next, 'Original owner must supply the sealed plan, physical rescue, assassination, coalition lead or winner Tech choice.');
|
||||
g = stepStandaloneHomeworldNexus(g, next, actions);
|
||||
}
|
||||
throw Error('Original standalone HW/Nexus chronology did not reach its owned boundary.');
|
||||
}
|
||||
export function assertStandaloneHomeworldNexusCustody(g: Game): void {
|
||||
homeworldGameIntegrity(g); validateLeaderSkills(g.leaderSkills!, g.players);
|
||||
validateNexusCards(g.nexusCards!.cards!, g.players);
|
||||
if (g.discoveries) validateDiscoveryState(g.discoveries);
|
||||
const skills = g.leaderSkills!, nx = g.nexusCards!.cards!;
|
||||
assert.deepEqual([...skills.deck, ...Object.values(skills.offers).flatMap(o => o.cards), ...skills.assignments.map(a => a.skill)].sort(),
|
||||
LEADER_SKILL_CARDS.map(c => c.id).sort());
|
||||
assert.deepEqual([...nx.deck, ...nx.discard, ...Object.values(nx.hands).filter((c): c is FactionId => c !== null)].sort(), [...NEXUS_FACTIONS].sort());
|
||||
const auction = g.auction?.cards.slice(g.auction.index + (g.currentAuctionSale ? 1 : 0)) ?? [];
|
||||
assert.deepEqual([...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand), ...auction].map(c => c.id).sort(),
|
||||
treacheryDeck(['ecaz']).map(c => c.id).sort());
|
||||
if (g.traitorReserve) {
|
||||
const retired = g.moritaniAssassinate?.opportunities.filter(o => o.stage === 'replaced').map(o => o.card!) ?? [];
|
||||
assert.deepEqual([...g.traitorReserve, ...g.players.flatMap(p => [...p.traitors, ...p.traitorChoices]), ...retired,
|
||||
...(g.ecazLoyalty?.card ? [g.ecazLoyalty.card] : [])].sort(), traitorDeck(g.players).sort());
|
||||
}
|
||||
// Physical native reserves and the separate Duke are produced only AFTER
|
||||
// original Traitor selection; unassigned setup has no home custody to count.
|
||||
if (!g.homeworlds?.custody) return;
|
||||
if (g.ecazAmbassadors) validateAmbassadors(g.ecazAmbassadors);
|
||||
const homes = homeworldForceGroups(homeworldContext(g), g.homeworlds!.custody!);
|
||||
for (const p of g.players) {
|
||||
const visitors = homes.filter(h => h.native !== p.id).map(h => h.forces[p.id] ?? { normal: 0, elite: 0 });
|
||||
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0) +
|
||||
visitors.reduce((n, f) => n + f.normal + f.elite, 0), 20, p.faction);
|
||||
if (p.elites) assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((a, b) => a + b, 0) +
|
||||
visitors.reduce((n, f) => n + f.elite, 0), p.faction === 'fremen' ? 3 : 5);
|
||||
assert.deepEqual(p.leaders.map(l => l.id).sort(), leaders(p.faction).map(l => l.id).sort());
|
||||
}
|
||||
assert.equal(g.dukeVidal!.leader.id, DUKE_VIDAL_ID);
|
||||
assert.ok(!skills.assignments.some(a => a.leader === DUKE_VIDAL_ID));
|
||||
}
|
||||
export function createStandaloneHomeworldNexusSetup(options: StandaloneHomeworldNexusOptions = {}): StandaloneHomeworldNexusSetup {
|
||||
const actions: StandaloneHomeworldNexusStep[] = [];
|
||||
const suppliedSetup = options.initial?.status === 'setup';
|
||||
let g = suppliedSetup ? reloadStandaloneHomeworldNexus(options.initial!) : createStandaloneHomeworldNexusLobby(options);
|
||||
const initial = reloadStandaloneHomeworldNexus(g);
|
||||
const owner = g.players.find(p => p.faction === 'ecaz' || p.faction === 'moritani')!.id;
|
||||
const opponent = g.players.find(p => p.faction === 'guild')?.id ?? g.players.find(p => p.id !== owner)!.id;
|
||||
if (!suppliedSetup) {
|
||||
for (const p of g.players) if (!p.ready) g = stepStandaloneHomeworldNexus(g, { actor: p.id, action: { type: 'ready' } }, actions);
|
||||
if (options.initial) g = initializeLeaderSkillsGameForAudit(g);
|
||||
else {
|
||||
const requested = options.skill ?? 'warmaster';
|
||||
const opposing: LeaderSkillId = requested === 'suk-graduate' ? 'swordmaster-of-ginaz' : 'suk-graduate';
|
||||
const passive: LeaderSkillId[] = ['master-of-assassins', 'killer-medic', 'prana-bindu-adept', 'swordmaster-of-ginaz', 'planetologist']
|
||||
.filter(s => s !== requested && s !== opposing) as LeaderSkillId[];
|
||||
const selected: LeaderSkillId[] = g.players.map(p => p.id === owner ? requested
|
||||
: p.id === opponent ? opposing : passive.shift()!);
|
||||
const source = LEADER_SKILL_CARDS.map(c => c.id), rest = source.filter(c => !selected.includes(c));
|
||||
assert.equal(new Set(selected).size, selected.length, 'A generated programme needs distinct requested physical skills.');
|
||||
const desired = selected.flatMap(c => [c, rest.shift()!]).concat(rest);
|
||||
const cards = treacheryDeck(['ecaz']), remaining = [...cards];
|
||||
const front = g.players.flatMap(p => {
|
||||
const kinds = [p.id === opponent && g.players.some(s => s.faction === 'moritani') ? 'projectile' : 'worthless',
|
||||
...(p.faction === 'harkonnen' ? ['shield'] : [])];
|
||||
return kinds.map(kind => {
|
||||
const selectedKind = kind === 'worthless' && !remaining.some(c => c.kind === 'worthless') ? 'shield' : kind;
|
||||
const index = remaining.findIndex(c => c.kind === selectedKind); assert.ok(index >= 0);
|
||||
return remaining.splice(index, 1)[0].id;
|
||||
});
|
||||
});
|
||||
const native = standaloneHomeworldNexusPlayer(g, owner).faction;
|
||||
const rolls = [...permutation(source, desired), ...(g.discoveryEnabled ? Array<number>(7).fill(0xffffffff) : []),
|
||||
...permutation(NEXUS_FACTIONS, [native, ...NEXUS_FACTIONS.filter(c => c !== native)]),
|
||||
...permutation(cards.map(c => c.id), [...front, ...remaining.map(c => c.id)])];
|
||||
g = lottery(rolls, () => initializeLeaderSkillsGameForAudit(g));
|
||||
}
|
||||
}
|
||||
assert.ok(g.status === 'setup' && g.turn === 1 && g.phase === 0 && ['prediction', 'leaderSkills'].includes(g.setupStage!) &&
|
||||
g.leaderSkills?.assignments.length === 0 && g.homeworlds && g.nexusCards?.cards,
|
||||
'Continue only ORIGINAL unassigned skill-first setup; never convert a played save.');
|
||||
const offered = reloadStandaloneHomeworldNexus(g);
|
||||
g = completeDiscoveryStandaloneNexusE3SkillsSetup(g, { initial: options.initial,
|
||||
skill: options.skill ?? (!options.initial ? 'warmaster' : undefined) }, actions);
|
||||
assertStandaloneHomeworldNexusCustody(g);
|
||||
const trainer = g.leaderSkills!.assignments.find(a => a.owner === owner)!.leader;
|
||||
return { initial, offered, afterSetup: g, owner, opponent, trainer, actions };
|
||||
}
|
||||
|
||||
/** Only ORIGINAL unplayed Spice Cards are reordered, never cards in either played pile. */
|
||||
export function orderStandaloneHomeworldNexusSpice(g: Game, worm = false): void {
|
||||
const kinds = [...(worm ? ['worm'] : []), ...Array<string>(g.advanced ? 2 : 1).fill('land')];
|
||||
for (const [position, kind] of kinds.entries()) {
|
||||
const index = g.spiceDeck.findIndex((c, i) => i >= position && (kind === 'worm'
|
||||
? 'worm' in c && !c.greatMaker : 'territory' in c && !c.discovery));
|
||||
assert.ok(index >= position); g.spiceDeck.splice(position, 0, g.spiceDeck.splice(index, 1)[0]);
|
||||
}
|
||||
}
|
||||
export function createStandaloneHomeworldNexusClosing(options: StandaloneHomeworldNexusOptions = {}): StandaloneHomeworldNexusClosing {
|
||||
const f = createStandaloneHomeworldNexusSetup(options);
|
||||
assert.ok(f.afterSetup.players.length >= 3, 'A qualifying native closing draw needs two other real alliance seats.');
|
||||
let g = reloadStandaloneHomeworldNexus(f.afterSetup);
|
||||
orderStandaloneHomeworldNexusSpice(g);
|
||||
g = advanceStandaloneHomeworldNexus(g, s => s.phase === 1 && standaloneHomeworldNexusClean(s), f.actions);
|
||||
const firstStorm = reloadStandaloneHomeworldNexus(g);
|
||||
g = advanceStandaloneHomeworldNexus(g, s => s.phase === 8 && standaloneHomeworldNexusClean(s), f.actions);
|
||||
let firstMentat: StandaloneHomeworldNexusTransition | undefined;
|
||||
while (g.turn === 1) {
|
||||
const before = reloadStandaloneHomeworldNexus(g), step = nextStandaloneHomeworldNexusStep(g); assert.ok(step);
|
||||
g = stepStandaloneHomeworldNexus(g, step, f.actions);
|
||||
if (g.turn === 2) firstMentat = { before, step, after: reloadStandaloneHomeworldNexus(g) };
|
||||
}
|
||||
assert.ok(firstMentat);
|
||||
g = advanceStandaloneHomeworldNexus(g, s => s.phase === 1 && standaloneHomeworldNexusClean(s), f.actions);
|
||||
orderStandaloneHomeworldNexusSpice(g, true);
|
||||
g = advanceStandaloneHomeworldNexus(g, s => s.nexus && !s.spiceWindow && !s.spiceResolution && standaloneHomeworldNexusClean(s), f.actions);
|
||||
const others = g.players.filter(p => p.id !== f.owner);
|
||||
for (const [actor, target] of [[others[0].id, others[1].id], [others[1].id, others[0].id]])
|
||||
g = stepStandaloneHomeworldNexus(g, { actor, action: { type: 'alliance', target } }, f.actions);
|
||||
const alliance = reloadStandaloneHomeworldNexus(g);
|
||||
g = advanceStandaloneHomeworldNexus(g, s => s.nexusCards?.phase?.stage === 'drawing' && standaloneHomeworldNexusClean(s), f.actions);
|
||||
assert.ok(g.nexusCards!.phase!.eligible.includes(f.owner));
|
||||
const before = reloadStandaloneHomeworldNexus(g), step: StandaloneHomeworldNexusStep = { actor: f.owner,
|
||||
action: { type: 'nexusCardChoice', turn: g.turn, card: g.nexusCards!.cards!.hands[f.owner], choice: 'draw', ownRedraws: 0 } };
|
||||
g = stepStandaloneHomeworldNexus(g, step, f.actions);
|
||||
return { ...f, firstStorm, firstMentat, alliance, draw: { before, step, after: reloadStandaloneHomeworldNexus(g) }, game: g };
|
||||
}
|
||||
/** The ORIGINAL undealt Treachery supply selects these two auction faces; only
|
||||
* real one-spice winning bids and native payment put them into Moritani's hand. */
|
||||
export function buyStandaloneHomeworldMoritaniArmament(f: StandaloneHomeworldNexusClosing, state: Game) {
|
||||
let g = advanceStandaloneHomeworldNexus(state, s => s.phase === 2 && standaloneHomeworldNexusClean(s), f.actions);
|
||||
const before = reloadStandaloneHomeworldNexus(g), cards: string[] = [], front: Game['deck'] = [];
|
||||
for (const kind of ['projectile', 'shield']) {
|
||||
const held = standaloneHomeworldNexusPlayer(g, f.owner).hand.find(c => c.kind === kind);
|
||||
if (held) { cards.push(held.id); continue; }
|
||||
const index = g.deck.findIndex(c => c.kind === kind); assert.ok(index >= 0);
|
||||
const card = g.deck.splice(index, 1)[0]; cards.push(card.id); front.push(card);
|
||||
}
|
||||
g.deck.unshift(...front);
|
||||
for (let n = 0; n < 320; n++) {
|
||||
if (cards.every(id => standaloneHomeworldNexusPlayer(g, f.owner).hand.some(c => c.id === id)))
|
||||
return { before, game: g, weapon: cards[0], defense: cards[1], bought: front.map(c => c.id) };
|
||||
const owned = nextStandaloneHomeworldNexusStep(g); assert.ok(owned);
|
||||
if (g.phase === 3 && standaloneHomeworldNexusClean(g) && g.auction && !g.auction.bid &&
|
||||
g.auction.active === f.owner && cards.includes(g.auction.cards[g.auction.index]?.id))
|
||||
owned.action = { type: 'bid', amount: 1 };
|
||||
g = stepStandaloneHomeworldNexus(g, owned, f.actions);
|
||||
}
|
||||
throw Error('The original paid auction did not deliver Moritani armament.');
|
||||
}
|
||||
|
||||
export function invadeStandaloneHomeworldNexus(g: Game, actor: string, destination: string, amount: number,
|
||||
actions?: StandaloneHomeworldNexusStep[]): StandaloneHomeworldNexusTransition & { settled: Game } {
|
||||
g = advanceStandaloneHomeworldNexus(g, s => s.phase === 5 && s.active === actor && standaloneHomeworldNexusClean(s), actions);
|
||||
const before = reloadStandaloneHomeworldNexus(g), view = viewGame(g, actor);
|
||||
const sources = nativeShipmentSources(view, amount, 0); assert.ok(sources);
|
||||
const selected = Object.fromEntries(Object.entries(sources).filter(([, f]) => f.normal + f.elite > 0));
|
||||
const choice = homeworldShipmentChoice(view, destination, selected); assert.ok(choice.action, choice.blocked ?? 'Original invasion unavailable.');
|
||||
const step = { actor, action: choice.action };
|
||||
g = stepStandaloneHomeworldNexus(g, step, actions);
|
||||
const after = reloadStandaloneHomeworldNexus(g);
|
||||
g = advanceStandaloneHomeworldNexus(g, s => !s.pendingHomeworldShipment && !s.pendingShipment && standaloneHomeworldNexusClean(s), actions);
|
||||
return { before, step, after, settled: g };
|
||||
}
|
||||
export function openStandaloneHomeworldNexusBattle(g: Game, location: string, first: string, second: string,
|
||||
actions?: StandaloneHomeworldNexusStep[]): Game {
|
||||
g = advanceStandaloneHomeworldNexus(g, s => s.phase === 6 && standaloneHomeworldNexusClean(s) && !!s.active, actions);
|
||||
assert.ok(g.active === first || g.active === second);
|
||||
g = stepStandaloneHomeworldNexus(g, { actor: g.active!, action: { type: 'chooseBattle', territory: location,
|
||||
target: g.active === first ? second : first } }, actions);
|
||||
return advanceStandaloneHomeworldNexus(g, s => !!s.battle && standaloneHomeworldNexusClean(s) &&
|
||||
!s.battle.preparation && s.battle.preLeader?.closed !== false, actions);
|
||||
}
|
||||
export function revealStandaloneHomeworldNexusPlans(g: Game, plans: StandaloneHomeworldNexusStep[],
|
||||
actions?: StandaloneHomeworldNexusStep[]): Game {
|
||||
for (const step of plans) {
|
||||
g = stepStandaloneHomeworldNexus(g, step, actions);
|
||||
if (!g.battle?.revealed) g = advanceStandaloneHomeworldNexus(g, s => !!s.battle && standaloneHomeworldNexusClean(s) &&
|
||||
!s.battle.preparation && s.battle.preLeader?.closed !== false, actions);
|
||||
}
|
||||
assert.ok(g.battle?.revealed); return g;
|
||||
}
|
||||
export function settleStandaloneHomeworldNexus(g: Game, actions?: StandaloneHomeworldNexusStep[]): DiscoveryStandaloneNexusE3SkillsBoundary {
|
||||
const choices: Partial<Record<NonNullable<Game['decision']>['kind'], DiscoveryStandaloneNexusE3SkillsBoundary['kind']>> = {
|
||||
battleLosses: 'losses', sukRescue: 'rescue', moritaniAssassinate: 'assassination', battleCards: 'cards', techToken: 'tech',
|
||||
};
|
||||
g = advanceStandaloneHomeworldNexus(g, s => !s.response && !s.phaseOpening &&
|
||||
(!!(s.decision && choices[s.decision.kind]) || !s.battle && standaloneHomeworldNexusClean(s)), actions);
|
||||
return { game: g, kind: g.decision && choices[g.decision.kind] || 'settled' };
|
||||
}
|
||||
export function createStandaloneHomeworldEcazCunning(options: StandaloneHomeworldNexusOptions = {}) {
|
||||
const f = createStandaloneHomeworldNexusClosing({ ...options, faction: 'ecaz', advanced: true,
|
||||
roster: ['ecaz', 'guild', 'beneGesserit'], tech: options.tech ?? true, strongholds: options.strongholds ?? true });
|
||||
const opponent = f.afterSetup.players.find(p => p.faction === 'beneGesserit')!.id;
|
||||
const arrival = invadeStandaloneHomeworldNexus(f.game, f.owner, 'homeworld:beneGesserit', 1, f.actions);
|
||||
let g = advanceStandaloneHomeworldNexus(arrival.settled, s => s.phase === 6 && standaloneHomeworldNexusClean(s) && !s.battle, f.actions);
|
||||
const before = reloadStandaloneHomeworldNexus(g), step = ecazNexusSkillsModulesCunningRequest(g, f.owner);
|
||||
g = stepStandaloneHomeworldNexus(g, step, f.actions);
|
||||
const cunning = { before, step, after: reloadStandaloneHomeworldNexus(g) };
|
||||
g = openStandaloneHomeworldNexusBattle(g, 'homeworld:beneGesserit', f.owner, opponent, f.actions);
|
||||
const trained = new Set(g.leaderSkills!.assignments.map(a => a.leader));
|
||||
const own = standaloneHomeworldNexusPlayer(g, f.owner).leaders.filter(l => !trained.has(l.id)).sort((a, b) => a.strength - b.strength)[0];
|
||||
const enemy = standaloneHomeworldNexusPlayer(g, opponent).leaders.filter(l => !trained.has(l.id)).sort((a, b) => b.strength - a.strength)[0];
|
||||
assert.ok(own && enemy);
|
||||
const plans: StandaloneHomeworldNexusStep[] = [
|
||||
{ actor: f.owner, action: { type: 'battlePlan', leader: own.id, dial: 0, support: 0 } },
|
||||
{ actor: opponent, action: { type: 'battlePlan', leader: enemy.id, dial: 3, support: 3 } },
|
||||
];
|
||||
return { ...f, opponent, arrival, cunning, game: g, plans, location: 'homeworld:beneGesserit' };
|
||||
}
|
||||
|
||||
/** First original loss: the unused living Guild trainer remains eligible for
|
||||
* native private assassination only AFTER its real same-home normal Suk rescue. */
|
||||
export function openStandaloneHomeworldMoritaniDeath(f: StandaloneHomeworldNexusClosing, state: Game, leader: string,
|
||||
first = false): StandaloneHomeworldNexusBattle {
|
||||
const arrival = invadeStandaloneHomeworldNexus(state, f.owner, 'homeworld:guild', 1, f.actions);
|
||||
const beforeBattle = advanceStandaloneHomeworldNexus(arrival.settled,
|
||||
s => s.phase === 6 && standaloneHomeworldNexusClean(s), f.actions);
|
||||
const g = openStandaloneHomeworldNexusBattle(beforeBattle, 'homeworld:guild', f.owner, f.opponent, f.actions);
|
||||
const trained = new Set(g.leaderSkills!.assignments.map(a => a.leader));
|
||||
const winner = standaloneHomeworldNexusPlayer(g, f.opponent).leaders.filter(l => !l.dead && !trained.has(l.id))
|
||||
.sort((a, b) => b.strength - a.strength)[0];
|
||||
const weapon = standaloneHomeworldNexusPlayer(g, f.opponent).hand.find(c => c.kind === 'projectile');
|
||||
assert.ok(winner && weapon, 'The genuine Guild starting deal must provide its living winner and retained projectile.');
|
||||
const worthless = first ? standaloneHomeworldNexusPlayer(g, f.owner).hand.find(c => c.kind === 'worthless')?.id : undefined;
|
||||
return { arrival, settledArrival: arrival.settled, beforeBattle, game: g, location: 'homeworld:guild', plans: [
|
||||
{ actor: f.owner, action: { type: 'battlePlan', leader, dial: 0, support: 0, weapon: worthless } },
|
||||
{ actor: f.opponent, action: { type: 'battlePlan', leader: winner.id, dial: first ? 1 : 0,
|
||||
support: g.advanced && first ? 1 : 0, weapon: weapon.id } },
|
||||
] };
|
||||
}
|
||||
|
||||
/** Four later Guild discs die by actual paid invasions, against an actually
|
||||
* auctioned native projectile and Shield. The assassinated fifth disc is never
|
||||
* restaged; the Guild must establish all-five deaths to revive its OWN trainer. */
|
||||
export function openStandaloneHomeworldGuildDeath(f: StandaloneHomeworldNexusClosing, state: Game, leader: string,
|
||||
weapon: string, defense: string): StandaloneHomeworldNexusBattle {
|
||||
const arrival = invadeStandaloneHomeworldNexus(state, f.opponent, 'homeworld:moritani', 1, f.actions);
|
||||
const beforeBattle = advanceStandaloneHomeworldNexus(arrival.settled,
|
||||
s => s.phase === 6 && standaloneHomeworldNexusClean(s), f.actions);
|
||||
const g = openStandaloneHomeworldNexusBattle(beforeBattle, 'homeworld:moritani', f.owner, f.opponent, f.actions);
|
||||
const own = standaloneHomeworldNexusPlayer(g, f.owner);
|
||||
assert.ok(own.hand.some(c => c.id === weapon) && own.hand.some(c => c.id === defense));
|
||||
const winner = own.leaders.filter(l => !l.dead &&
|
||||
!g.leaderSkills!.assignments.some(a => a.owner === f.owner && a.leader === l.id))
|
||||
.sort((a, b) => b.strength - a.strength)[0]; assert.ok(winner);
|
||||
const enemyWeapon = standaloneHomeworldNexusPlayer(g, f.opponent).hand.find(c => c.kind === 'projectile');
|
||||
return { arrival, settledArrival: arrival.settled, beforeBattle, game: g, location: 'homeworld:moritani', plans: [
|
||||
{ actor: f.owner, action: { type: 'battlePlan', leader: winner.id, dial: 1, support: g.advanced ? 1 : 0, weapon, defense } },
|
||||
{ actor: f.opponent, action: { type: 'battlePlan', leader, dial: 0, support: 0, weapon: enemyWeapon?.id } },
|
||||
] };
|
||||
}
|
||||
|
||||
/** Optional nested entry uses the original supply lottery, paid parent shipment,
|
||||
* real inspection/reveal and next-turn free move; native reserves never move at entry. */
|
||||
export function createStandaloneHomeworldNexusDiscovery(options: StandaloneHomeworldNexusOptions = {}) {
|
||||
const f = createStandaloneHomeworldNexusSetup({ ...options, discovery: true });
|
||||
let g = reloadStandaloneHomeworldNexus(f.afterSetup);
|
||||
const index = g.spiceDeck.findIndex(c => 'territory' in c && c.discovery === 'discovery-hagga-basin'); assert.ok(index >= 0);
|
||||
g.spiceDeck.unshift(g.spiceDeck.splice(index, 1)[0]);
|
||||
const position = g.spiceDeck.findIndex((c, i) => i > 0 && 'territory' in c && !c.discovery && c.territory !== 'hagga_basin');
|
||||
assert.ok(position > 0); g.spiceDeck.splice(1, 0, g.spiceDeck.splice(position, 1)[0]);
|
||||
g = advanceStandaloneHomeworldNexus(g, s => s.phase === 1 && standaloneHomeworldNexusClean(s), f.actions);
|
||||
for (let n = 0; !g.discoveries!.tokens.some(t => t.face === 'cistern' && t.status === 'placed') && n < 160; n++) {
|
||||
const step = nextStandaloneHomeworldNexusStep(g); assert.ok(step); f.actions.push(structuredClone(step));
|
||||
g = withClassicDiscoveryNexusToken(g, 'cistern', () => applyAction(reloadStandaloneHomeworldNexus(g), step.actor, step.action));
|
||||
}
|
||||
g = advanceStandaloneHomeworldNexus(g, s => s.phase === 5 && s.active === f.owner && standaloneHomeworldNexusClean(s), f.actions);
|
||||
const beforeShipment = reloadStandaloneHomeworldNexus(g), destination = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'];
|
||||
const sources = nativeShipmentSources(viewGame(g, f.owner), 2, 0); assert.ok(sources);
|
||||
const shipment: StandaloneHomeworldNexusStep = { actor: f.owner, action: { type: 'ship', territory: destination.territory,
|
||||
sector: destination.sector, amount: 2, elite: 0, allyPayment: 0, homeworldSources: sources } };
|
||||
g = stepStandaloneHomeworldNexus(g, shipment, f.actions);
|
||||
g = advanceStandaloneHomeworldNexus(g, s => !s.pendingShipment && standaloneHomeworldNexusClean(s), f.actions);
|
||||
const afterShipment = reloadStandaloneHomeworldNexus(g);
|
||||
g = advanceStandaloneHomeworldNexus(g, s => s.phase === 7 && standaloneHomeworldNexusClean(s), f.actions);
|
||||
const token = g.discoveries!.tokens.find(t => t.face === 'cistern' && t.status === 'placed')!.id;
|
||||
if (viewGame(g, f.owner).discoveries!.canInspect.includes(token))
|
||||
g = stepStandaloneHomeworldNexus(g, { actor: f.owner, action: { type: 'discovery', token, reveal: false } }, f.actions);
|
||||
g = stepStandaloneHomeworldNexus(g, { actor: f.owner, action: { type: 'discovery', token, reveal: true } }, f.actions);
|
||||
const revealed = reloadStandaloneHomeworldNexus(g);
|
||||
g = advanceStandaloneHomeworldNexus(g, s => s.turn === 2 && !s.response && !s.phaseOpening &&
|
||||
s.decision?.kind === 'discoveryEntry' && s.decision.player === f.owner, f.actions);
|
||||
const before = reloadStandaloneHomeworldNexus(g), view = viewGame(g, f.owner);
|
||||
const action = discoveryEntryMoveAction(view, view.discoveryEntry!.sources); assert.ok(action);
|
||||
const step = { actor: f.owner, action }; g = stepStandaloneHomeworldNexus(g, step, f.actions);
|
||||
return { ...f, beforeShipment, shipment, afterShipment, revealed, token, entry: { before, step, after: g }, game: g };
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
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 { territory } from '../game/board';
|
||||
import { ownedTech } from '../game/tech-tokens';
|
||||
import { quotePhaseResources } from '../game/phase-resource-quote';
|
||||
import { quoteSpiceCollection } from '../game/board-resolution-quote';
|
||||
import { discoveryPairedNexusE2SkillsCunningAction } from './fixture-discovery-paired-nexus-e2-skills';
|
||||
import { pairedSkillsChoamCunningRequest, quotePairedSkillsBattle } from './fixture-paired-choam-nexus-skills';
|
||||
import {
|
||||
advanceHomeworldPairedNexus as advance, createHomeworldPairedNexusProgramme as programme,
|
||||
finishHomeworldPairedNexusBattle as finishBattle, homeworldPairedNexusArmy as army,
|
||||
homeworldPairedNexusClean as clean, homeworldPairedNexusInventory as inventory,
|
||||
homeworldPairedNexusLeader as leader, homeworldPairedNexusPlayer as player,
|
||||
openHomeworldPairedNexusBattle as openBattle, revealHomeworldPairedNexusPlans as revealPlans,
|
||||
settleHomeworldPairedNexus as settle, shipHomeworldPairedNexus as ship,
|
||||
stepHomeworldPairedNexus as step, type HomeworldPairedNexusStep,
|
||||
} from './fixture-homeworld-paired-nexus';
|
||||
|
||||
/** Every consumer begins with the original initializer, actual owned actions,
|
||||
* conserved unused lotteries and unplayed Spice order. No held card, board
|
||||
* position, wallet, alliance, outcome or authenticated receipt is assigned. */
|
||||
function reject(game: Game, actor: string, action: Action): void {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before, 'Rejected consumers leave physical stock and payments unchanged.');
|
||||
}
|
||||
function policies(game: Game, actor: string, select: (action: Action) => boolean,
|
||||
outcome: (after: Game, action: Action) => void): void {
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game, actor); view.players.find(p => p.id === actor)!.bot = difficulty;
|
||||
const action = botActions(view).find(select); assert.ok(action, `${difficulty} selects the actual owned window`);
|
||||
const after = applyAction(structuredClone(game), actor, action);
|
||||
outcome(after, action); inventory(after);
|
||||
}
|
||||
}
|
||||
function consumeSuboids(game: Game, owner: string, actions?: HomeworldPairedNexusStep[]): Game {
|
||||
const offer = viewGame(game, owner).nexusSuboids?.offer; assert.ok(offer && !offer.blocked);
|
||||
const action: Action = { type: 'nexusSuboids', event: offer.event };
|
||||
reject(game, owner, { ...action, event: 'obsolete-suboid-battle' });
|
||||
policies(game, owner, a => a.type === 'nexusSuboids', after => {
|
||||
const resolved = settle(after);
|
||||
assert.equal(resolved.nexusCards!.cards!.hands[owner], null);
|
||||
assert.equal(resolved.nexusCards!.cards!.discard.filter(c => c === 'ixians').length, 1);
|
||||
assert.deepEqual(player(resolved, owner).forces, player(game, owner).forces);
|
||||
assert.equal(viewGame(resolved, owner).battle!.ownForces!.normalFreeSupport, true);
|
||||
});
|
||||
const played = settle(step(game, { actor: owner, action }, actions), actions);
|
||||
assert.equal(played.nexusCards!.cards!.hands[owner], null);
|
||||
assert.equal(played.nexusCards!.cards!.discard.filter(c => c === 'ixians').length, 1);
|
||||
assert.equal(viewGame(played, owner).battle!.ownForces!.normalFreeSupport, true);
|
||||
reject(played, owner, action); inventory(played);
|
||||
return played;
|
||||
}
|
||||
function atMovement(game: Game, actor: string, turn = 2): Game {
|
||||
return advance(game, g => g.turn === turn && g.phase === 5 && g.active === actor && clean(g));
|
||||
}
|
||||
|
||||
void test('Basic paired E1 without Skills or Discovery genuinely draws matching Cunning, invades native Ix and preserves foreign-Homeworld Face Dance suppression', () => {
|
||||
const f = programme({ advanced: false, skills: false, discovery: false, tech: true, strongholds: false });
|
||||
assert.equal(player(f.alliance, f.guild).ally, f.fremen);
|
||||
assert.equal(player(f.alliance, f.fremen).ally, f.guild);
|
||||
policies(f.draws[0].before, f.owner, a => a.type === 'nexusCardChoice', after => {
|
||||
assert.equal(after.nexusCards!.cards!.hands[f.owner], 'ixians');
|
||||
assert.equal(player(after, f.owner).spice, player(f.draws[0].before, f.owner).spice);
|
||||
assert.deepEqual(after.homeworlds!.custody, f.draws[0].before.homeworlds!.custody);
|
||||
});
|
||||
for (const draw of f.draws) {
|
||||
assert.equal(draw.before.nexusCards!.cards!.hands[draw.step.actor], null);
|
||||
assert.equal(draw.after.nexusCards!.cards!.hands[draw.step.actor], player(draw.after, draw.step.actor).faction);
|
||||
assert.equal(player(draw.after, draw.step.actor).spice, player(draw.before, draw.step.actor).spice);
|
||||
}
|
||||
let game = ship(atMovement(f.game, f.guild), f.guild, 'homeworld:ixians', 3);
|
||||
game = consumeSuboids(openBattle(game, f.owner, f.guild, 'homeworld:ixians', false), f.owner);
|
||||
const own = leader(game, f.owner, true), before = structuredClone(game), lostTech = ownedTech(game.techTokens, f.guild);
|
||||
game = revealPlans(game, [{ actor: f.owner, action: { type: 'battlePlan', leader: own, dial: 0, support: 0 } },
|
||||
{ actor: f.guild, action: { type: 'battlePlan', leader: leader(game, f.guild), dial: 0, support: 0 } }]);
|
||||
game = finishBattle(game);
|
||||
assert.equal(game.lastBattleContext!.winner, f.owner);
|
||||
assert.deepEqual(army(game, f.owner, 'homeworld:ixians'), army(before, f.owner, 'homeworld:ixians'));
|
||||
assert.deepEqual(army(game, f.guild, 'homeworld:ixians'), { normal: 0, elite: 0 });
|
||||
assert.equal(player(game, f.guild).tanks, player(before, f.guild).tanks + 3);
|
||||
for (const token of lostTech) assert.equal(game.techTokens![token].owner, f.owner);
|
||||
assert.equal(player(game, f.owner).leaders.find(l => l.id === own)!.dead, false);
|
||||
assert.ok(player(game, f.native).faceDancers!.every(c => !c.revealed));
|
||||
reject(game, f.native, { type: 'decision', reveal: true, sources: { reserves: 1 }, sector: 0 });
|
||||
inventory(game);
|
||||
});
|
||||
|
||||
for (const visitor of [false, true]) void test(`Advanced matching Suboid Cunning and ${visitor ? 'visitor' : 'native'} typed Suk leave only real residual Cyborg Tanks available for equal Suboid exchange`, () => {
|
||||
const f = programme({ advanced: true, skills: true, discovery: false, tech: true, strongholds: true });
|
||||
assert.ok(f.trainer);
|
||||
const destination = visitor ? 'homeworld:guild' : 'homeworld:ixians';
|
||||
const sender = visitor ? f.owner : f.guild;
|
||||
let game = ship(atMovement(f.game, sender), sender, destination, visitor ? 4 : 3, visitor ? 3 : 0);
|
||||
game = consumeSuboids(openBattle(game, f.owner, f.guild, destination), f.owner);
|
||||
const before = structuredClone(game), enemyTech = ownedTech(game.techTokens, f.guild);
|
||||
game = revealPlans(game, [{ actor: f.owner, action: { type: 'battlePlan', leader: f.trainer, dial: 3, support: 0 } },
|
||||
{ actor: f.guild, action: { type: 'battlePlan', leader: leader(game, f.guild), dial: 0, support: 0 } }]);
|
||||
game = advance(game, g => g.decision?.kind === 'sukRescue');
|
||||
const d = game.decision; assert.ok(d?.kind === 'sukRescue');
|
||||
assert.deepEqual(army(game, f.owner, destination), army(before, f.owner, destination), 'Casualty commitment has not yet killed or moved physical counters.');
|
||||
policies(game, f.owner, a => a.type === 'decision' && a.choice !== undefined, (after, action) => {
|
||||
const rescue = d.options[Number(action.choice)], saved = rescue.normal + rescue.elite;
|
||||
assert.equal(player(after, f.owner).tanks, player(game, f.owner).tanks + 3 - saved);
|
||||
assert.equal(player(after, f.owner).elites!.tanks, player(game, f.owner).elites!.tanks + 3 - rescue.elite);
|
||||
assert.deepEqual(army(after, f.owner, 'homeworld:ixians'), visitor
|
||||
? { normal: army(game, f.owner, 'homeworld:ixians').normal,
|
||||
elite: army(game, f.owner, 'homeworld:ixians').elite + rescue.elite - (rescue.kept?.kind === 'elite' ? 1 : 0) }
|
||||
: { normal: army(game, f.owner, destination).normal, elite: army(game, f.owner, destination).elite - 3 + rescue.elite });
|
||||
});
|
||||
const choice = d.options.findIndex(o => o.normal === 0 && o.elite === 1 && o.kept?.kind === 'elite'); assert.ok(choice >= 0);
|
||||
const rescueAction: Action = { type: 'decision', event: d.event, choice };
|
||||
reject(game, f.guild, rescueAction);
|
||||
reject(game, f.owner, { ...rescueAction, event: 'obsolete-suk' });
|
||||
reject(game, f.owner, { ...rescueAction, destinations: { 'homeworld:ixians': { normal: 0, elite: 1 } } });
|
||||
game = step(JSON.parse(JSON.stringify(game)) as Game, { actor: f.owner, action: rescueAction });
|
||||
game = advance(game, g => g.decision?.kind === 'ixSubstitution');
|
||||
const physical = game.lastBattleContext!.sukRescue!.physical!;
|
||||
assert.equal(physical.source, visitor ? 'visitor-homeworld' : 'native-homeworld');
|
||||
assert.deepEqual(physical.saved, { normal: 0, elite: 1 });
|
||||
assert.deepEqual(physical.tanks, { normal: 0, elite: 2 });
|
||||
assert.deepEqual(physical.destinations, {});
|
||||
assert.deepEqual(game.pendingIxSubstitution!.losses, { [destination]: 2 });
|
||||
const residual = structuredClone(game), pool = army(game, f.owner, destination);
|
||||
const exchange: Action = { type: 'decision', sources: { [destination]: 1 }, recover: { [destination]: 1 } };
|
||||
reject(game, f.owner, { ...exchange, recover: { [destination]: 3 } });
|
||||
reject(game, f.owner, { ...exchange, sources: { reserves: 1 } });
|
||||
reject(game, f.owner, { ...exchange, recover: { 'homeworld:tleilaxu': 1 } });
|
||||
policies(game, f.owner, a => a.type === 'decision' && (a.decline === true || a.sources !== undefined), (after, action) => {
|
||||
const count = action.decline ? 0 : Object.values((action.recover ?? {}) as Record<string, number>).reduce((sum, n) => sum + n, 0);
|
||||
assert.equal(player(after, f.owner).elites!.tanks, player(residual, f.owner).elites!.tanks - count);
|
||||
assert.deepEqual(army(after, f.owner, destination), { normal: pool.normal - count, elite: pool.elite + count });
|
||||
assert.equal(player(after, f.owner).spice, player(residual, f.owner).spice);
|
||||
});
|
||||
game = step(game, { actor: f.owner, action: exchange });
|
||||
game = advance(game, g => !g.pendingIxSubstitution);
|
||||
assert.deepEqual(army(game, f.owner, destination), { normal: pool.normal - 1, elite: pool.elite + 1 });
|
||||
assert.equal(player(game, f.owner).elites!.tanks, player(residual, f.owner).elites!.tanks - 1);
|
||||
assert.equal(player(game, f.owner).tanks, player(residual, f.owner).tanks);
|
||||
assert.equal(player(game, f.owner).spice, player(residual, f.owner).spice);
|
||||
if (visitor) assert.deepEqual(army(game, f.owner, 'homeworld:ixians'), army(residual, f.owner, 'homeworld:ixians'));
|
||||
reject(game, f.owner, exchange);
|
||||
game = finishBattle(game);
|
||||
assert.equal(game.lastBattleContext!.winner, f.owner);
|
||||
for (const token of enemyTech) assert.equal(game.techTokens![token].owner, f.owner);
|
||||
assert.ok(player(game, f.native).faceDancers!.every(c => !c.revealed), 'Foreign Homeworld never opens the forbidden native replacement.');
|
||||
inventory(game);
|
||||
});
|
||||
|
||||
void test('actual Maker entry and matching nested Cunning preserve typed sources; cleanup precedes winner Tech and legal matching Face Dance without a borrowed-native grant', () => {
|
||||
const f = programme({ advanced: true, discovery: true, skills: true, tech: true, strongholds: true });
|
||||
assert.ok(f.entry && f.ride && f.vote && f.trainer);
|
||||
const entered = f.entry.after;
|
||||
assert.equal(player(entered, f.owner).forces['shrine:0'], 2);
|
||||
assert.equal(player(entered, f.owner).elites!.forces['shrine:0'], 1);
|
||||
assert.deepEqual(entered.homeworlds!.custody, f.entry.before.homeworlds!.custody);
|
||||
assert.equal(player(entered, f.owner).spice, player(f.entry.before, f.owner).spice);
|
||||
policies(f.entry.before, f.owner, a => a.type === 'decision' && a.accept === false, after => {
|
||||
assert.equal(player(after, f.owner).forces['shrine:0'] ?? 0, 0);
|
||||
assert.deepEqual(player(after, f.owner).forces, player(f.entry!.before, f.owner).forces);
|
||||
assert.deepEqual(after.homeworlds!.custody, f.entry!.before.homeworlds!.custody);
|
||||
});
|
||||
const rideBefore = player(f.ride.before, f.fremen), rideAfter = player(f.ride.after, f.fremen);
|
||||
assert.equal(rideAfter.reserves, rideBefore.reserves - 2);
|
||||
assert.equal(rideAfter.elites!.reserves, rideBefore.elites!.reserves - 1);
|
||||
assert.equal(rideAfter.forces['polar_sink:0'], (rideBefore.forces['polar_sink:0'] ?? 0) + 2);
|
||||
assert.equal(rideAfter.spice, rideBefore.spice);
|
||||
let game = ship(atMovement(f.game, f.native), f.native, 'shrine', 2);
|
||||
game = consumeSuboids(openBattle(game, f.owner, f.native, 'shrine'), f.owner);
|
||||
assert.ok(player(game, f.native).faceDancers!.some(c => c.leader === f.trainer && !c.revealed));
|
||||
const worthless = player(game, f.owner).hand.find(c => c.kind === 'worthless'); assert.ok(worthless);
|
||||
game = revealPlans(game, [{ actor: f.owner, action: { type: 'battlePlan', leader: f.trainer, dial: 0, support: 0, weapon: worthless.id } },
|
||||
{ actor: f.native, action: { type: 'battlePlan', leader: leader(game, f.native), dial: 0, support: 0 } }]);
|
||||
game = advance(game, g => g.decision?.kind === 'battleCards');
|
||||
assert.equal(game.techTokens!.axlotl.owner, f.native);
|
||||
policies(game, f.owner, a => a.type === 'decision' && a.discard !== undefined, (after, action) => {
|
||||
const discarded = action.discard as string[];
|
||||
assert.equal(player(after, f.owner).hand.some(c => c.id === worthless.id), !discarded.includes(worthless.id));
|
||||
assert.equal(after.discard.some(c => c.id === worthless.id), discarded.includes(worthless.id));
|
||||
});
|
||||
game = step(game, { actor: f.owner, action: { type: 'decision', discard: [worthless.id] } });
|
||||
game = advance(game, g => g.decision?.kind === 'faceDance');
|
||||
assert.equal(game.techTokens!.axlotl.owner, f.owner);
|
||||
assert.equal(player(game, f.owner).hand.some(c => c.id === worthless.id), false);
|
||||
assert.equal(game.discard.filter(c => c.id === worthless.id).length, 1);
|
||||
const before = structuredClone(game), home = army(game, f.native, 'homeworld:tleilaxu');
|
||||
const dance: Action = { type: 'decision', reveal: true, sources: { reserves: 1 }, sector: 0 };
|
||||
reject(game, f.owner, dance);
|
||||
reject(game, f.native, { ...dance, sources: { reserves: 3 } });
|
||||
policies(game, f.native, a => a.type === 'decision' && a.reveal !== undefined, (after, action) => {
|
||||
const count = action.reveal ? Object.values((action.sources ?? {}) as Record<string, number>).reduce((sum, n) => sum + n, 0) : 0;
|
||||
assert.equal(player(after, f.owner).leaders.find(l => l.id === f.trainer)!.dead, !!action.reveal);
|
||||
assert.equal(player(after, f.native).faceDancers!.find(c => c.leader === f.trainer)!.revealed, !!action.reveal);
|
||||
assert.equal(player(after, f.owner).forces['shrine:0'] ?? 0, action.reveal ? 0 : 2);
|
||||
assert.equal(player(after, f.native).forces['shrine:0'] ?? 0, count);
|
||||
assert.deepEqual(after.techTokens, before.techTokens);
|
||||
});
|
||||
game = step(JSON.parse(JSON.stringify(game)) as Game, { actor: f.native, action: dance });
|
||||
assert.equal(player(game, f.owner).forces['shrine:0'] ?? 0, 0);
|
||||
assert.equal(player(game, f.owner).reserves, player(before, f.owner).reserves + 2);
|
||||
assert.equal(player(game, f.owner).elites!.reserves, player(before, f.owner).elites!.reserves + 1);
|
||||
assert.equal(player(game, f.native).forces['shrine:0'], 1);
|
||||
assert.deepEqual(army(game, f.native, 'homeworld:tleilaxu'), { normal: home.normal - 1, elite: home.elite });
|
||||
assert.equal(player(game, f.owner).leaders.find(l => l.id === f.trainer)!.dead, true);
|
||||
assert.equal(game.leaderSkills!.assignments.some(a => a.leader === f.trainer), false);
|
||||
assert.equal(game.leaderSkills!.deck.filter(c => c === 'suk-graduate').length, 1);
|
||||
assert.deepEqual(game.techTokens, before.techTokens, 'Face Dance cannot redo the already settled winner reward.');
|
||||
inventory(game);
|
||||
});
|
||||
|
||||
void test('nested Richese matching pair charges one signed invoice, keeps markers separate until reveal and debits actual Advanced support before Suk and winner Tech', () => {
|
||||
const f = programme({ family: 'choam', advanced: true, skills: true, discovery: true, tech: true, strongholds: true });
|
||||
assert.ok(f.firstMarker && f.trainer);
|
||||
assert.equal(player(f.firstMarker.after, f.owner).spice, player(f.firstMarker.before, f.owner).spice - 1);
|
||||
assert.equal(player(f.firstMarker.after, f.owner).reserves, player(f.firstMarker.before, f.owner).reserves);
|
||||
let game = atMovement(f.game, f.owner);
|
||||
const before = structuredClone(game), request = discoveryPairedNexusE2SkillsCunningAction(game, f.owner);
|
||||
reject(game, f.owner, { ...request, nexus: 'obsolete-pair' });
|
||||
reject(game, f.owner, { ...request, revealedToken: request.noField });
|
||||
reject(game, f.owner, { ...request, smuggler: true });
|
||||
game = step(game, { actor: f.owner, action: request });
|
||||
assert.equal(player(game, f.owner).spice, player(before, f.owner).spice, 'Declaration alone does not debit the invoice.');
|
||||
game = settle(game);
|
||||
assert.equal(player(game, f.owner).spice, player(before, f.owner).spice - 1);
|
||||
assert.equal(player(game, f.guild).spice, player(before, f.guild).spice + 1);
|
||||
assert.equal(player(game, f.owner).forces['shrine:0'], 5);
|
||||
assert.equal(player(game, f.owner).reserves, player(before, f.owner).reserves - 5);
|
||||
assert.equal(player(game, f.owner).noField!.deployed!.tokenId, request.noField);
|
||||
assert.equal(game.nexusCards!.cards!.hands[f.owner], null);
|
||||
const deployed = structuredClone(game), event = player(game, f.owner).noFieldEvent;
|
||||
reject(game, f.owner, request);
|
||||
reject(game, f.owner, { type: 'revealNoField', event: 'obsolete-reveal', token: request.noField });
|
||||
game = step(JSON.parse(JSON.stringify(game)) as Game, { actor: f.owner,
|
||||
action: { type: 'revealNoField', event, token: request.noField } });
|
||||
assert.equal(player(game, f.owner).forces['shrine:0'], 8);
|
||||
assert.equal(player(game, f.owner).reserves, player(deployed, f.owner).reserves - 3);
|
||||
assert.equal(player(game, f.owner).spice, player(deployed, f.owner).spice);
|
||||
assert.equal(player(game, f.guild).spice, player(deployed, f.guild).spice);
|
||||
game = openBattle(game, f.owner, f.guild, 'shrine');
|
||||
const beforePlans = structuredClone(game), lostTech = ownedTech(game.techTokens, f.guild);
|
||||
game = revealPlans(game, [{ actor: f.owner, action: { type: 'battlePlan', leader: f.trainer, dial: 2, support: 2 } },
|
||||
{ actor: f.guild, action: { type: 'battlePlan', leader: leader(game, f.guild), dial: 0, support: 0 } }]);
|
||||
assert.equal(player(game, f.owner).spice, player(beforePlans, f.owner).spice, 'Sealed/revealed support is not charged before resolution.');
|
||||
game = advance(game, g => g.decision?.kind === 'sukRescue');
|
||||
assert.equal(player(game, f.owner).spice, player(beforePlans, f.owner).spice - 2);
|
||||
assert.equal(player(game, f.native).spice, player(beforePlans, f.native).spice,
|
||||
'Native CHOAM income waits for the original skill aftermath; payment has already settled.');
|
||||
for (const token of lostTech) assert.equal(game.techTokens![token].owner, f.guild);
|
||||
const d = game.decision; assert.ok(d?.kind === 'sukRescue');
|
||||
const choice = d.options.findIndex(o => o.normal === 2 && o.elite === 0 && o.kept?.kind === 'normal'); assert.ok(choice >= 0);
|
||||
const home = army(game, f.owner, 'homeworld:richese'), funds = new Map(game.players.map(p => [p.id, p.spice]));
|
||||
game = step(game, { actor: f.owner, action: { type: 'decision', event: d.event, choice } });
|
||||
assert.equal(player(game, f.owner).forces['shrine:0'], 7);
|
||||
assert.deepEqual(army(game, f.owner, 'homeworld:richese'), { normal: home.normal + 1, elite: 0 });
|
||||
assert.equal(player(game, f.owner).tanks, 0);
|
||||
for (const seat of game.players)
|
||||
assert.equal(seat.spice, funds.get(seat.id)! + (seat.id === f.native ? 1 : 0),
|
||||
'Rescue does not repeat/refund support; the original pending CHOAM income settles after skill cleanup.');
|
||||
game = finishBattle(game);
|
||||
assert.equal(game.lastBattleContext!.winner, f.owner);
|
||||
assert.equal(player(game, f.guild).tanks, 3);
|
||||
for (const token of lostTech) assert.equal(game.techTokens![token].owner, f.owner);
|
||||
inventory(game);
|
||||
});
|
||||
|
||||
void test('real first-turn Arrakeen claim supplies source-local bank support after genuine pairing; held custody and phase-end industry pay only at their original boundaries', () => {
|
||||
const f = programme({ family: 'choam', advanced: true, discovery: false, skills: true, tech: true, strongholds: true, arrakeenClaim: true });
|
||||
assert.equal(f.game.strongholdCards!.owners.arrakeen, f.owner);
|
||||
const actors = new Set([f.owner, f.guild]);
|
||||
let game = f.game;
|
||||
while (actors.size) {
|
||||
game = advance(game, g => g.phase === 5 && clean(g) && actors.has(g.active!));
|
||||
const actor = game.active!;
|
||||
if (actor === f.owner) {
|
||||
const p = player(game, actor), offer = viewGame(game, actor).nexusRicheseCunning; assert.ok(offer && !offer.blocked);
|
||||
const request: Action = { type: 'ship', territory: 'arrakeen', sector: territory('arrakeen').sectors[0],
|
||||
noField: p.noField!.tokens.find(t => t.value === 0)!.id, revealedToken: p.noField!.tokens.find(t => t.value === 5)!.id,
|
||||
event: p.noFieldEvent, nexus: offer.event, allyPayment: 0 };
|
||||
game = settle(step(game, { actor, action: request }));
|
||||
game = step(game, { actor, action: { type: 'revealNoField', event: player(game, actor).noFieldEvent, token: request.noField } });
|
||||
} else game = ship(game, actor, 'arrakeen', 3);
|
||||
actors.delete(actor);
|
||||
if (actors.size) game = step(game, { actor, action: { type: 'endMovement' } });
|
||||
}
|
||||
const industry = game.techTokens!.heighliners, industryOwner = industry.owner; assert.ok(industryOwner);
|
||||
const credit = quotePhaseResources(game).credits.find(row => row.kind === 'tech' && row.token === 'heighliners'); assert.ok(credit);
|
||||
const beforeMovementEnd = structuredClone(game);
|
||||
game = openBattle(game, f.owner, f.guild, 'arrakeen');
|
||||
assert.equal(game.techTokens!.heighliners.spice, 0);
|
||||
assert.equal(player(game, industryOwner).spice, credit.balance);
|
||||
assert.equal(game.strongholdCards!.owners.arrakeen, f.owner, 'Contest does not transfer a held card before END Mentat.');
|
||||
assert.equal(viewGame(game, f.owner).battle!.strongholdEffects[f.owner], 'arrakeen');
|
||||
const beforePlans = structuredClone(game); assert.ok(f.trainer);
|
||||
game = revealPlans(game, [{ actor: f.owner, action: { type: 'battlePlan', leader: f.trainer, dial: 2, support: 2 } },
|
||||
{ actor: f.guild, action: { type: 'battlePlan', leader: leader(game, f.guild), dial: 0, support: 0 } }]);
|
||||
const quote = quotePairedSkillsBattle(game), payment = quote.payments.find(p => p.player === f.owner)!;
|
||||
assert.equal(payment.ownPayment, 0); assert.equal(payment.bankSupport, 2);
|
||||
game = advance(game, g => g.decision?.kind === 'sukRescue');
|
||||
assert.equal(player(game, f.owner).spice, player(beforePlans, f.owner).spice);
|
||||
assert.equal(player(game, f.native).spice, player(beforePlans, f.native).spice,
|
||||
'Bank support still earns the pending native income only after skill cleanup.');
|
||||
const d = game.decision; assert.ok(d?.kind === 'sukRescue');
|
||||
const choice = d.options.findIndex(o => o.normal === 2 && o.kept?.kind === 'normal'); assert.ok(choice >= 0);
|
||||
game = finishBattle(step(game, { actor: f.owner, action: { type: 'decision', event: d.event, choice } }));
|
||||
assert.equal(player(game, f.owner).forces[`arrakeen:${territory('arrakeen').sectors[0]}`], 7);
|
||||
assert.equal(player(game, f.owner).tanks, 0);
|
||||
assert.equal(game.strongholdCards!.owners.arrakeen, f.owner);
|
||||
assert.equal(beforeMovementEnd.techTokens!.heighliners.spice, ownedTech(beforeMovementEnd.techTokens, industryOwner).length);
|
||||
inventory(game);
|
||||
});
|
||||
|
||||
void test('actual CHOAM Special fuels matching Cunning once; original native payer support is charged at resolution and cannot use held Arrakeen from another source', () => {
|
||||
const f = programme({ family: 'choam', advanced: true, skills: true, discovery: false, tech: true, strongholds: true, arrakeenClaim: true });
|
||||
let game = atMovement(f.game, f.native);
|
||||
const fuel = player(game, f.native).hand.find(c => c.kind === 'special' && c.effect !== 'karama'); assert.ok(fuel);
|
||||
const action = pairedSkillsChoamCunningRequest(game, f.native, fuel.id);
|
||||
const before = structuredClone(game);
|
||||
reject(game, f.native, { ...action, nexus: 'obsolete-choam' });
|
||||
game = settle(step(game, { actor: f.native, action }));
|
||||
assert.equal(player(game, f.native).hand.some(c => c.id === fuel.id), false);
|
||||
assert.equal(game.discard.filter(c => c.id === fuel.id).length, 1);
|
||||
assert.equal(game.nexusCards!.cards!.hands[f.native], null);
|
||||
assert.equal(player(game, f.native).spice, player(before, f.native).spice);
|
||||
reject(game, f.native, action);
|
||||
game = ship(game, f.native, 'homeworld:guild', 3);
|
||||
game = openBattle(game, f.native, f.guild, 'homeworld:guild', false);
|
||||
assert.notEqual(viewGame(game, f.native).battle!.strongholdEffects[f.native], 'arrakeen');
|
||||
const ownLeader = leader(game, f.native, true), beforePlans = structuredClone(game);
|
||||
game = revealPlans(game, [{ actor: f.native, action: { type: 'battlePlan', leader: ownLeader, dial: 2, support: 2 } },
|
||||
{ actor: f.guild, action: { type: 'battlePlan', leader: leader(game, f.guild), dial: 0, support: 0 } }]);
|
||||
assert.equal(player(game, f.native).spice, player(beforePlans, f.native).spice);
|
||||
game = finishBattle(game);
|
||||
assert.equal(game.lastBattleContext!.winner, f.native);
|
||||
assert.equal(player(game, f.native).tanks, player(beforePlans, f.native).tanks + 2);
|
||||
assert.deepEqual(army(game, f.native, 'homeworld:guild'), { normal: 1, elite: 0 });
|
||||
// Collection may already have opened; separate its stronghold/harvest income from support.
|
||||
const collection = game.phase === 7 && clean(game) ? quoteSpiceCollection(game).receipts.find(r => r.player === f.native) : undefined;
|
||||
assert.equal(player(game, f.native).spice, player(beforePlans, f.native).spice - 2 +
|
||||
(collection?.strongholds ?? 0) + (collection?.collected ?? 0));
|
||||
assert.equal(game.discard.filter(c => c.id === fuel.id).length, 1);
|
||||
inventory(game);
|
||||
});
|
||||
|
||||
void test('genuine typed Shrine reinforcements make nested Suk precede only residual Cyborg substitution, with matching native Cunning already consumed', () => {
|
||||
const f = programme({ advanced: true, skills: true, discovery: true, tech: true, strongholds: true });
|
||||
assert.ok(f.trainer);
|
||||
const senders = new Set([f.owner, f.native]);
|
||||
let game = f.game;
|
||||
while (senders.size) {
|
||||
game = advance(game, g => g.turn === 2 && g.phase === 5 && clean(g) && senders.has(g.active!));
|
||||
const actor = game.active!;
|
||||
game = ship(game, actor, 'shrine', actor === f.owner ? 4 : 3, actor === f.owner ? 2 : 0);
|
||||
senders.delete(actor);
|
||||
if (senders.size) game = step(game, { actor, action: { type: 'endMovement' } });
|
||||
}
|
||||
assert.equal(player(game, f.owner).forces['shrine:0'], 6);
|
||||
assert.equal(player(game, f.owner).elites!.forces['shrine:0'], 3);
|
||||
game = consumeSuboids(openBattle(game, f.owner, f.native, 'shrine'), f.owner);
|
||||
const home = army(game, f.owner, 'homeworld:ixians'), priorTanks = player(game, f.owner).elites!.tanks;
|
||||
game = revealPlans(game, [{ actor: f.owner, action: { type: 'battlePlan', leader: f.trainer, dial: 3, support: 0 } },
|
||||
{ actor: f.native, action: { type: 'battlePlan', leader: leader(game, f.native), dial: 0, support: 0 } }]);
|
||||
game = advance(game, g => g.decision?.kind === 'sukRescue');
|
||||
const d = game.decision; assert.ok(d?.kind === 'sukRescue');
|
||||
const choice = d.options.findIndex(o => o.normal === 0 && o.elite === 1 && o.kept?.kind === 'elite'); assert.ok(choice >= 0);
|
||||
game = step(game, { actor: f.owner, action: { type: 'decision', event: d.event, choice } });
|
||||
game = advance(game, g => g.decision?.kind === 'ixSubstitution');
|
||||
assert.equal(player(game, f.owner).elites!.tanks, priorTanks + 2);
|
||||
assert.equal(player(game, f.owner).forces['shrine:0'], 4);
|
||||
assert.equal(player(game, f.owner).elites!.forces['shrine:0'], 1);
|
||||
assert.deepEqual(army(game, f.owner, 'homeworld:ixians'), home, 'The kept nested Cyborg is not also returned to native reserves.');
|
||||
assert.deepEqual(game.pendingIxSubstitution!.losses, { 'shrine:0': 2 });
|
||||
const before = structuredClone(game);
|
||||
const action: Action = { type: 'decision', sources: { 'shrine:0': 1 }, recover: { 'shrine:0': 1 } };
|
||||
reject(game, f.owner, { ...action, recover: { 'shrine:0': 3 } });
|
||||
reject(game, f.owner, { ...action, sources: { 'homeworld:ixians': 1 } });
|
||||
game = step(JSON.parse(JSON.stringify(game)) as Game, { actor: f.owner, action });
|
||||
game = advance(game, g => !g.pendingIxSubstitution);
|
||||
assert.equal(player(game, f.owner).forces['shrine:0'], 4);
|
||||
assert.equal(player(game, f.owner).elites!.forces['shrine:0'], 2);
|
||||
assert.equal(player(game, f.owner).elites!.tanks, player(before, f.owner).elites!.tanks - 1);
|
||||
assert.equal(player(game, f.owner).tanks, player(before, f.owner).tanks);
|
||||
assert.equal(player(game, f.owner).spice, player(before, f.owner).spice);
|
||||
assert.deepEqual(army(game, f.owner, 'homeworld:ixians'), home);
|
||||
game = finishBattle(game);
|
||||
assert.equal(game.lastBattleContext!.winner, f.owner);
|
||||
assert.equal(player(game, f.native).tanks, 3);
|
||||
assert.equal(player(game, f.native).faceDancers!.find(c => c.leader === f.trainer)!.revealed, false);
|
||||
inventory(game);
|
||||
});
|
||||
|
||||
void test('no-Skills native Richese pair at a revealed nested site is capped by reserves spent through four actual earlier shipments, not virtual materialization', () => {
|
||||
const f = programme({ family: 'choam', advanced: true, skills: false, discovery: true, tech: true, strongholds: true });
|
||||
let game = f.game;
|
||||
// Keep the genuinely drawn matching singleton while four ordinary markers
|
||||
// consume 5 + 3 + 5 + 3 physical native counters through their actual seats.
|
||||
for (const [index, value] of [5, 3, 5, 3].entries()) {
|
||||
const turn = index + 2;
|
||||
if (turn > 2) {
|
||||
game = advance(game, g => g.turn === turn && g.phase === 1 && clean(g));
|
||||
// Only unused land faces are conserved; held Nexus, played worms and
|
||||
// custody remain untouched. Two owned strongholds do not win the game.
|
||||
const lands = game.spiceDeck.filter(c => 'territory' in c && !c.discovery).slice(0, 2);
|
||||
for (const [position, card] of lands.entries())
|
||||
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(game.spiceDeck.indexOf(card), 1)[0]);
|
||||
}
|
||||
game = atMovement(game, f.owner, turn);
|
||||
const destination = ['sietch_tabr', 'habbanya_ridge_sietch'].map(id => territory(id))
|
||||
.find(t => !t.sectors.includes(game.storm) && game.players.filter(p => p.id !== f.owner)
|
||||
.every(p => Object.entries(p.forces).every(([key, amount]) => !amount || !key.startsWith(`${t.id}:`)))); assert.ok(destination);
|
||||
const p = player(game, f.owner), token = p.noField!.tokens.find(t => t.value === value)!;
|
||||
game = settle(step(game, { actor: f.owner, action: { type: 'ship', territory: destination.id,
|
||||
sector: destination.sectors[0], noField: token.id, event: p.noFieldEvent, allyPayment: 0 } }));
|
||||
game = step(game, { actor: f.owner, action: { type: 'revealNoField', token: token.id, event: player(game, f.owner).noFieldEvent } });
|
||||
assert.equal(game.nexusCards!.cards!.hands[f.owner], 'richese');
|
||||
inventory(game);
|
||||
}
|
||||
game = advance(game, g => g.turn === 6 && g.phase === 1 && clean(g));
|
||||
const lands = game.spiceDeck.filter(c => 'territory' in c && !c.discovery).slice(0, 2);
|
||||
for (const [position, card] of lands.entries())
|
||||
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(game.spiceDeck.indexOf(card), 1)[0]);
|
||||
game = advance(game, g => g.turn === 6 && g.phase === 2 && clean(g));
|
||||
game = step(game, { actor: f.owner, action: { type: 'charity' } });
|
||||
game = atMovement(game, f.owner, 6);
|
||||
assert.equal(player(game, f.owner).reserves, 4);
|
||||
const p = player(game, f.owner), offer = viewGame(game, f.owner).nexusRicheseCunning; assert.ok(offer && !offer.blocked);
|
||||
const hidden = p.noField!.tokens.find(t => t.value === 5)!;
|
||||
const action: Action = { type: 'ship', territory: 'shrine', sector: 0, allyPayment: 0,
|
||||
noField: hidden.id, revealedToken: p.noField!.tokens.find(t => t.value === 0)!.id,
|
||||
event: p.noFieldEvent, nexus: offer.event };
|
||||
const before = structuredClone(game);
|
||||
game = settle(step(game, { actor: f.owner, action }));
|
||||
assert.equal(player(game, f.owner).forces['shrine:0'] ?? 0, 0);
|
||||
assert.equal(player(game, f.owner).reserves, 4);
|
||||
assert.equal(player(game, f.owner).noField!.deployed!.tokenId, hidden.id);
|
||||
assert.equal(game.nexusCards!.cards!.hands[f.owner], null);
|
||||
assert.equal(player(game, f.owner).spice, player(before, f.owner).spice - 1);
|
||||
assert.equal(player(game, f.guild).spice, player(before, f.guild).spice + 1);
|
||||
reject(game, f.owner, action);
|
||||
const shipped = structuredClone(game);
|
||||
game = step(JSON.parse(JSON.stringify(game)) as Game, { actor: f.owner,
|
||||
action: { type: 'revealNoField', token: hidden.id, event: player(game, f.owner).noFieldEvent } });
|
||||
assert.equal(player(game, f.owner).forces['shrine:0'], 4);
|
||||
assert.equal(player(game, f.owner).spice, player(shipped, f.owner).spice);
|
||||
assert.equal(player(game, f.guild).spice, player(shipped, f.guild).spice);
|
||||
assert.equal(player(game, f.owner).reserves, 0);
|
||||
assert.equal(player(game, f.owner).noField!.lastShipped, hidden.id);
|
||||
inventory(game);
|
||||
});
|
||||
@@ -0,0 +1,405 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, viewGame,
|
||||
type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
|
||||
import { ownedTech } from '../game/tech-tokens';
|
||||
import { strongholdControllers } from '../game/stronghold-cards';
|
||||
import { ecazOccupancyRelation } from '../game/ecaz-occupy';
|
||||
import { nativeShipmentSources } from '../game/homeworld-options';
|
||||
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
|
||||
import { nexusEcazDukeAction } from '../game/nexus-ecaz-duke-options';
|
||||
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
|
||||
import {
|
||||
advanceStandaloneHomeworldNexus as advance, assertStandaloneHomeworldNexusCustody as custody,
|
||||
buyStandaloneHomeworldMoritaniArmament as buyArmament, openStandaloneHomeworldGuildDeath as guildDeathBattle,
|
||||
createStandaloneHomeworldEcazCunning as ecazProgramme,
|
||||
createStandaloneHomeworldNexusClosing as closing, createStandaloneHomeworldNexusDiscovery as discovery,
|
||||
createStandaloneHomeworldNexusSetup as setup,
|
||||
nextStandaloneHomeworldNexusStep as next, openStandaloneHomeworldMoritaniDeath as deathBattle,
|
||||
orderStandaloneHomeworldNexusSpice as orderSpice, reloadStandaloneHomeworldNexus as reload,
|
||||
revealStandaloneHomeworldNexusPlans as reveal, settleStandaloneHomeworldNexus as settle,
|
||||
standaloneHomeworldNexusClean as clean, standaloneHomeworldNexusPlayer as player,
|
||||
standaloneHomeworldNexusPolicy as policy, standaloneHomeworldNexusPool as pool,
|
||||
stepStandaloneHomeworldNexus as step, type StandaloneHomeworldNexusStep,
|
||||
} from './fixture-homeworld-standalone-nexus';
|
||||
|
||||
function reject(g: Game, actor: string, action: Action, reason?: RegExp): void {
|
||||
const before = structuredClone(g);
|
||||
if (reason) assert.throws(() => applyAction(g, actor, action), reason);
|
||||
else assert.throws(() => applyAction(g, actor, action));
|
||||
assert.deepEqual(g, before);
|
||||
}
|
||||
/** Every difficulty acts at a genuine window and demonstrates its physical
|
||||
* consumer transition. Applying an action without throwing is not the proof. */
|
||||
function legalPolicies(g: Game, actor: string, consume: (after: Game, action: Action) => void): void {
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const offered = policy(g, actor, difficulty); assert.ok(offered[0], `${difficulty} needs an actual owned choice.`);
|
||||
const after = applyAction(reload(g), actor, offered[0]);
|
||||
consume(after, offered[0]); custody(after);
|
||||
}
|
||||
}
|
||||
function finishSettlement(g: Game, actions?: StandaloneHomeworldNexusStep[]): Game {
|
||||
for (let n = 0; n < 80; n++) {
|
||||
const boundary = settle(g, actions); g = boundary.game;
|
||||
if (boundary.kind === 'settled') return g;
|
||||
const d = g.decision; assert.ok(d);
|
||||
let action: Action;
|
||||
if (d.kind === 'battleLosses') action = { type: 'decision', choice: 0 };
|
||||
else if (d.kind === 'battleCards') action = { type: 'decision', discard: [] };
|
||||
else if (d.kind === 'sukRescue') action = { type: 'decision', event: d.event, choice: 0 };
|
||||
else if (d.kind === 'moritaniAssassinate') action = { type: 'decision', event: d.event, decline: true };
|
||||
else if (d.kind === 'techToken') {
|
||||
const target = d.choices[0];
|
||||
assert.equal(g.battle, null, 'Original battle disposal precedes mandatory winner Tech.');
|
||||
assert.ok(!g.pendingSukRescue && !g.pendingWinnerDiscards);
|
||||
for (const entry of g.lastBattleContext!.mandatoryDiscard?.entries ?? [])
|
||||
assert.ok(g.discard.some(c => c.id === entry.card), 'Played cards are physically cleaned before the reward.');
|
||||
reject(g, d.loser, { type: 'decision', token: target });
|
||||
reject(g, d.player, { type: 'decision', decline: true });
|
||||
legalPolicies(g, d.player, (after, selected) => {
|
||||
const token = selected.token as keyof NonNullable<Game['techTokens']>;
|
||||
assert.ok(d.choices.includes(token)); assert.equal(after.techTokens![token].owner, d.player);
|
||||
});
|
||||
action = { type: 'decision', token: target };
|
||||
} else throw Error(`Unexpected original settlement boundary ${d.kind}`);
|
||||
g = step(g, { actor: d.player, action }, actions);
|
||||
}
|
||||
throw Error('Original physical settlement did not finish.');
|
||||
}
|
||||
|
||||
void test('a genuine native closing deal and living unclaimed Ecaz Cunning coexist with real native Wallach custody, never Intrusion or Homeworld Occupy', () => {
|
||||
const f = ecazProgramme({ discovery: true });
|
||||
assert.equal(f.firstStorm.techTokens && ownedTech(f.firstStorm.techTokens, f.owner).length, 1);
|
||||
assert.deepEqual(f.firstMentat.after.strongholdCards!.owners, strongholdControllers(f.firstMentat.before.players, false));
|
||||
assert.equal(f.afterSetup.dukeVidal!.controller, null);
|
||||
assert.equal(f.afterSetup.dukeVidal!.leader.dead, false);
|
||||
assert.equal(player(f.alliance, f.owner).ally, null);
|
||||
assert.equal(f.draw.before.nexusCards!.cards!.hands[f.owner], null);
|
||||
assert.equal(f.draw.after.nexusCards!.cards!.hands[f.owner], 'ecaz');
|
||||
assert.ok(!f.draw.after.nexusCards!.cards!.deck.includes('ecaz'));
|
||||
legalPolicies(f.draw.before, f.owner, after => {
|
||||
assert.equal(after.nexusCards!.cards!.hands[f.owner], 'ecaz');
|
||||
assert.ok(!after.nexusCards!.cards!.deck.includes('ecaz'));
|
||||
});
|
||||
reject(f.draw.before, f.opponent, f.draw.step.action);
|
||||
reject(f.draw.after, f.owner, f.draw.step.action);
|
||||
|
||||
const shipper = player(f.arrival.before, f.owner), arrived = player(f.arrival.settled, f.owner);
|
||||
const sources = nativeShipmentSources(viewGame(f.arrival.before, f.owner), 1, 0); assert.ok(sources);
|
||||
const choice = homeworldShipmentChoice(viewGame(f.arrival.before, f.owner), f.location,
|
||||
Object.fromEntries(Object.entries(sources).filter(([, group]) => group.normal + group.elite > 0)));
|
||||
assert.equal(arrived.spice, shipper.spice - choice.cost);
|
||||
assert.equal(arrived.reserves, shipper.reserves - 1);
|
||||
assert.equal(pool(f.arrival.settled, f.owner, f.location).normal, 1);
|
||||
assert.deepEqual(pool(f.arrival.settled, f.opponent, f.location), pool(f.arrival.before, f.opponent, f.location));
|
||||
assert.ok(!f.arrival.settled.pendingAmbassador && !f.arrival.settled.pendingTerrorEntry);
|
||||
assert.notEqual(f.arrival.settled.decision?.kind, 'intrusion');
|
||||
assert.ok(!Object.keys(arrived.forces).some(key => key.startsWith('homeworld:')));
|
||||
assert.ok(!Object.keys(player(f.arrival.settled, f.opponent).advisors ?? {}).some(key => key.startsWith('homeworld:')));
|
||||
assert.equal(f.game.battle!.territory, f.location);
|
||||
custody(f.arrival.settled);
|
||||
|
||||
const action = nexusEcazDukeAction(viewGame(f.cunning.before, f.owner)); assert.ok(action);
|
||||
reject(f.cunning.before, f.opponent, action);
|
||||
reject(f.cunning.before, f.owner, { ...action, event: 'stale' });
|
||||
legalPolicies(f.cunning.before, f.owner, after => {
|
||||
assert.equal(after.dukeVidal!.controller, f.owner);
|
||||
assert.equal(after.nexusCards!.cards!.hands[f.owner], null);
|
||||
assert.equal(after.nexusCards!.cards!.discard.filter(c => c === 'ecaz').length, 1);
|
||||
});
|
||||
assert.equal(f.cunning.after.dukeVidal!.controller, f.owner);
|
||||
assert.equal(f.cunning.after.dukeVidal!.source, 'ecazNexus');
|
||||
assert.equal(f.cunning.after.dukeVidal!.leader.dead, false);
|
||||
assert.ok(!f.cunning.after.leaderSkills!.assignments.some(a => a.leader === DUKE_VIDAL_ID));
|
||||
assert.ok(player(f.cunning.after, f.owner).leaders.every(l => l.id !== DUKE_VIDAL_ID));
|
||||
reject(f.cunning.after, f.owner, action);
|
||||
reject(f.game, f.owner, action, /interaction|unavailable|current/i);
|
||||
|
||||
const revealed = reveal(f.game, f.plans, f.actions);
|
||||
assert.equal(player(revealed, f.opponent).spice, player(f.game, f.opponent).spice,
|
||||
'The real sealed plan does not debit dialed support at reveal.');
|
||||
const done = finishSettlement(revealed, f.actions);
|
||||
assert.equal(done.lastBattleContext!.winner, f.opponent);
|
||||
assert.equal(player(done, f.opponent).spice, player(revealed, f.opponent).spice - 3,
|
||||
'Free native strength never pays for three dialed support.');
|
||||
assert.equal(player(done, f.opponent).tanks, player(revealed, f.opponent).tanks + 3);
|
||||
assert.equal(pool(done, f.opponent, f.location).normal, pool(revealed, f.opponent, f.location).normal - 3);
|
||||
assert.equal(player(done, f.owner).tanks, player(revealed, f.owner).tanks + 1);
|
||||
assert.equal(pool(done, f.owner, f.location).normal, 0);
|
||||
assert.equal(done.dukeVidal!.leader.dead, false);
|
||||
assert.equal(done.leaderSkills!.assignments.find(a => a.owner === f.owner)!.leader, f.trainer);
|
||||
const token = ownedTech(revealed.techTokens, f.owner)[0]; assert.ok(token);
|
||||
assert.equal(done.techTokens![token].owner, f.opponent);
|
||||
custody(done);
|
||||
|
||||
// The genuine next Nexus changes the alliance, not the physical identity of
|
||||
// either counter pool. Paid Arrakis shipments may now occupy one territory;
|
||||
// those same seats still cannot use allied native-home shipment as Occupy.
|
||||
let g = advance(done, s => s.turn === 3 && s.phase === 1 && clean(s), f.actions);
|
||||
assert.equal(g.dukeVidal!.controller, null);
|
||||
orderSpice(g, true);
|
||||
g = advance(g, s => s.nexus && !s.spiceWindow && !s.spiceResolution && clean(s), f.actions);
|
||||
const guild = g.players.find(p => p.faction === 'guild')!.id;
|
||||
g = step(g, { actor: guild, action: { type: 'alliance', target: null } }, f.actions);
|
||||
for (const [actor, target] of [[f.owner, guild], [guild, f.owner]])
|
||||
g = step(g, { actor, action: { type: 'alliance', target } }, f.actions);
|
||||
assert.equal(ecazOccupancyRelation(g.players, f.owner, guild, { kind: 'territory', id: 'polar_sink' }), 'ecazAlliance');
|
||||
assert.equal(ecazOccupancyRelation(g.players, f.owner, guild, { kind: 'homeworld', id: 'homeworld:guild' }), 'different');
|
||||
const shipped: Record<string, boolean> = {};
|
||||
for (let n = 0; n < 240; n++) {
|
||||
if (shipped[f.owner] && shipped[guild] && g.phase >= 6 && clean(g)) break;
|
||||
if (g.phase === 5 && clean(g) && g.active && !shipped[g.active] && [f.owner, guild].includes(g.active)) {
|
||||
const actor = g.active, view = viewGame(g, actor), own = player(g, actor);
|
||||
const reserves = nativeShipmentSources(view, 1, 0); assert.ok(reserves);
|
||||
if (actor === f.owner) {
|
||||
const alliedHome = homeworldShipmentChoice(view, 'homeworld:guild', reserves);
|
||||
assert.equal(alliedHome.action, null);
|
||||
assert.ok(alliedHome.blocked);
|
||||
}
|
||||
const before = reload(g);
|
||||
g = step(g, { actor, action: { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1,
|
||||
elite: 0, allyPayment: 0, homeworldSources: reserves } }, f.actions);
|
||||
g = advance(g, s => !s.pendingShipment && clean(s), f.actions);
|
||||
assert.equal(player(g, actor).reserves, own.reserves - 1);
|
||||
assert.equal(player(g, actor).forces['polar_sink:0'], (player(before, actor).forces['polar_sink:0'] ?? 0) + 1);
|
||||
assert.ok(player(g, actor).spice < own.spice);
|
||||
shipped[actor] = true;
|
||||
} else {
|
||||
const owned = next(g); assert.ok(owned); g = step(g, owned, f.actions);
|
||||
}
|
||||
}
|
||||
assert.ok(shipped[f.owner] && shipped[guild]);
|
||||
assert.equal(player(g, f.owner).forces['polar_sink:0'], 1);
|
||||
assert.equal(player(g, guild).forces['polar_sink:0'], 1);
|
||||
assert.equal(g.battle, null, 'Original allied Arrakis Occupy is not a battle between those two seats.');
|
||||
custody(g);
|
||||
});
|
||||
|
||||
void test('original Moritani private rescue → assassination → exact death Skill return → card cleanup/Tech → private replacement precedes five actual own deaths and paid own revival', () => {
|
||||
const f = closing({ faction: 'moritani', advanced: true, discovery: true, tech: true, strongholds: true });
|
||||
assert.equal(f.draw.after.nexusCards!.cards!.hands[f.owner], 'moritani');
|
||||
const target = f.afterSetup.leaderSkills!.assignments.find(a => a.owner === f.opponent)!.leader;
|
||||
assert.equal(player(f.afterSetup, f.opponent).leaders.find(l => l.id === target)!.name, 'Master Bewt');
|
||||
assert.ok(player(f.afterSetup, f.owner).traitors.includes(target));
|
||||
const firstDisc = player(f.afterSetup, f.owner).leaders.find(l => l.id !== f.trainer)!.id;
|
||||
const discs = [firstDisc, ...player(f.afterSetup, f.opponent).leaders.map(l => l.id).filter(id => id !== target)];
|
||||
const purchased = buyArmament(f, f.game);
|
||||
assert.equal(player(purchased.game, f.owner).spice, player(purchased.before, f.owner).spice - purchased.bought.length);
|
||||
assert.ok(player(purchased.game, f.owner).hand.some(c => c.id === purchased.weapon));
|
||||
assert.ok(player(purchased.game, f.owner).hand.some(c => c.id === purchased.defense));
|
||||
let g = purchased.game;
|
||||
for (const [index, disc] of discs.entries()) {
|
||||
const battle = index === 0 ? deathBattle(f, g, disc, true)
|
||||
: guildDeathBattle(f, g, disc, purchased.weapon, purchased.defense);
|
||||
const visitor = index === 0 ? f.owner : f.opponent;
|
||||
assert.equal(player(battle.settledArrival, visitor).reserves, player(battle.arrival.before, visitor).reserves - 1);
|
||||
assert.equal(pool(battle.settledArrival, visitor, battle.location).normal, 1);
|
||||
assert.ok(player(battle.settledArrival, visitor).spice < player(battle.arrival.before, visitor).spice);
|
||||
const revealed = reveal(battle.game, battle.plans, f.actions);
|
||||
assert.equal(player(revealed, f.opponent).spice, player(battle.game, f.opponent).spice);
|
||||
g = revealed;
|
||||
if (index === 0) {
|
||||
let boundary = settle(g, f.actions);
|
||||
for (let n = 0; boundary.kind === 'losses' || boundary.kind === 'rescue'; n++) {
|
||||
assert.ok(n < 12);
|
||||
const d = boundary.game.decision; assert.ok(d);
|
||||
g = step(boundary.game, { actor: d.player, action: { type: 'decision', ...(d.kind === 'sukRescue' ? { event: d.event } : {}), choice: 0 } }, f.actions);
|
||||
boundary = settle(g, f.actions);
|
||||
}
|
||||
assert.equal(boundary.kind, 'assassination'); g = boundary.game;
|
||||
assert.equal(g.lastBattleContext!.sukRescue!.completed, true);
|
||||
assert.deepEqual(g.lastBattleContext!.sukRescue!.physical!.saved, { normal: 1, elite: 0 });
|
||||
assert.equal(player(g, f.opponent).tanks, player(revealed, f.opponent).tanks);
|
||||
assert.equal(pool(g, f.opponent, 'homeworld:guild').normal, pool(revealed, f.opponent, 'homeworld:guild').normal,
|
||||
'Native rescue saves its real dialed casualty back at the SAME home.');
|
||||
assert.equal(player(g, f.opponent).spice, player(revealed, f.opponent).spice - 1 +
|
||||
player(revealed, f.owner).leaders.find(l => l.id === disc)!.strength,
|
||||
'Ordinary paid support and the killed opposing-disc reward both settle before assassination.');
|
||||
assert.equal(player(g, f.owner).spice, player(revealed, f.owner).spice,
|
||||
'A native-home loss pays no Arrakis Worthless income; the separate assassination bounty is still pending.');
|
||||
assert.equal(player(g, f.owner).leaders.find(l => l.id === disc)!.dead, true);
|
||||
assert.equal(player(g, f.opponent).leaders.find(l => l.id === target)!.dead, false);
|
||||
assert.notEqual(revealed.battle!.plans[f.opponent].leader, target);
|
||||
const d = g.decision; assert.ok(d?.kind === 'moritaniAssassinate');
|
||||
const bounty = player(g, f.opponent).leaders.find(l => l.id === target)!.strength;
|
||||
const reserve = [...g.traitorReserve!], held = [...player(g, f.owner).traitors], before = reload(g);
|
||||
const pendingToken = ownedTech(g.techTokens, f.owner)[0]; assert.ok(pendingToken);
|
||||
assert.equal(g.techTokens![pendingToken].owner, f.owner, 'Winner reward has not bypassed the private aftermath.');
|
||||
const action: Action = { type: 'decision', event: d.event, card: target };
|
||||
reject(g, f.opponent, action);
|
||||
reject(g, f.owner, { ...action, event: 'stale' });
|
||||
reject(g, f.owner, { ...action, card: revealed.battle!.plans[f.opponent].leader });
|
||||
legalPolicies(g, f.owner, after => {
|
||||
assert.equal(player(after, f.opponent).leaders.find(l => l.id === target)!.dead, true);
|
||||
assert.equal(player(after, f.owner).spice, player(before, f.owner).spice + bounty);
|
||||
assert.equal(after.leaderSkills!.assignments.some(a => a.leader === target), false);
|
||||
assert.equal(after.leaderSkills!.deck.filter(s => s === 'suk-graduate').length, 1);
|
||||
});
|
||||
g = step(g, { actor: f.owner, action }, f.actions);
|
||||
assert.equal(player(g, f.opponent).leaders.find(l => l.id === target)!.deaths,
|
||||
player(before, f.opponent).leaders.find(l => l.id === target)!.deaths + 1);
|
||||
assert.deepEqual(g.homeworlds!.custody, before.homeworlds!.custody);
|
||||
assert.equal(player(g, f.opponent).tanks, player(before, f.opponent).tanks);
|
||||
assert.equal(g.leaderSkills!.assignments.some(a => a.leader === target), false);
|
||||
assert.equal(g.leaderSkills!.deck.filter(s => s === 'suk-graduate').length, 1);
|
||||
g = finishSettlement(g, f.actions);
|
||||
const worthless = battle.plans[0].action.weapon as string; assert.ok(worthless);
|
||||
assert.ok(g.discard.some(c => c.id === worthless));
|
||||
assert.ok(!player(g, f.owner).hand.some(c => c.id === worthless));
|
||||
const token = ownedTech(before.techTokens, f.owner)[0]; assert.ok(token);
|
||||
assert.equal(g.techTokens![token].owner, f.opponent);
|
||||
assert.equal(player(g, f.owner).spice, player(before, f.owner).spice + bounty);
|
||||
g = advance(g, s => s.phase === 8, f.actions);
|
||||
const replacement = g.moritaniAssassinate!.opportunities.at(-1)!;
|
||||
assert.equal(replacement.stage, 'replaced'); assert.equal(replacement.replacement, reserve[0]);
|
||||
assert.deepEqual(g.traitorReserve, reserve.slice(1));
|
||||
assert.deepEqual(player(g, f.owner).traitors, [...held.filter(c => c !== target), reserve[0]]);
|
||||
assert.equal('replacement' in viewGame(g, f.opponent).moritaniAssassinate!.history.at(-1)!, false);
|
||||
} else g = finishSettlement(g, f.actions);
|
||||
const victim = index === 0 ? f.owner : f.opponent, winner = index === 0 ? f.opponent : f.owner;
|
||||
assert.equal(g.lastBattleContext!.winner, winner);
|
||||
assert.equal(player(g, f.opponent).leaders.filter(l => l.dead).length, index + 1);
|
||||
assert.equal(player(g, victim).leaders.find(l => l.id === disc)!.deaths,
|
||||
player(battle.game, victim).leaders.find(l => l.id === disc)!.deaths + 1);
|
||||
assert.equal(player(g, victim).tanks, player(revealed, victim).tanks + 1);
|
||||
assert.equal(pool(g, victim, battle.location).normal, 0);
|
||||
if (index > 0) {
|
||||
assert.equal(player(g, f.owner).leaders.filter(l => l.dead).length, 1,
|
||||
'The actual native winner stays alive; auctioned Shield stops the original Guild projectile before its loser cleanup.');
|
||||
assert.equal(player(g, f.owner).tanks, player(revealed, f.owner).tanks + 1);
|
||||
assert.equal(pool(g, f.owner, battle.location).normal, pool(revealed, f.owner, battle.location).normal - 1);
|
||||
assert.equal(player(g, f.owner).spice, player(revealed, f.owner).spice - 1 +
|
||||
player(revealed, f.opponent).leaders.find(l => l.id === disc)!.strength,
|
||||
'The actual native winner pays support and receives the ordinary opposing-leader death reward once.');
|
||||
for (const card of [purchased.weapon, purchased.defense]) {
|
||||
assert.ok(player(g, f.owner).hand.some(c => c.id === card));
|
||||
assert.ok(!g.discard.some(c => c.id === card));
|
||||
}
|
||||
if (battle.plans[1].action.weapon) {
|
||||
assert.ok(g.discard.some(c => c.id === battle.plans[1].action.weapon));
|
||||
assert.ok(!player(g, f.opponent).hand.some(c => c.id === battle.plans[1].action.weapon));
|
||||
}
|
||||
} else {
|
||||
const weapon = battle.plans[1].action.weapon as string;
|
||||
assert.equal(player(g, f.opponent).hand.filter(c => c.id === weapon).length, 1);
|
||||
assert.ok(!g.discard.some(c => c.id === weapon));
|
||||
}
|
||||
custody(g);
|
||||
const turn = g.turn;
|
||||
g = advance(g, s => s.turn === turn + 1 && s.phase === 1 && clean(s), f.actions);
|
||||
orderSpice(g);
|
||||
g = advance(g, s => s.phase === 4 && clean(s), f.actions);
|
||||
if (index < discs.length - 1) reject(g, f.opponent, { type: 'reviveLeader', leader: target }, /All leaders must die/);
|
||||
}
|
||||
assert.equal(player(g, f.opponent).leaders.filter(l => l.dead).length, 5);
|
||||
assert.equal(g.leaderSkills!.assignments.some(a => a.owner === f.opponent), false);
|
||||
assert.equal(g.leaderSkills!.deck.filter(s => s === 'suk-graduate').length, 1);
|
||||
assert.equal(g.leaderSkills!.assignments.find(a => a.owner === f.owner)!.leader, f.trainer);
|
||||
const before = reload(g), dead = player(g, f.opponent).leaders.find(l => l.id === target)!;
|
||||
reject(g, f.owner, { type: 'reviveLeader', leader: target });
|
||||
g = step(g, { actor: f.opponent, action: { type: 'reviveLeader', leader: target } }, f.actions);
|
||||
g = advance(g, s => !!s.leaderSkills!.offers[f.opponent], f.actions);
|
||||
assert.equal(player(g, f.opponent).spice, player(before, f.opponent).spice - dead.strength);
|
||||
assert.equal(player(g, f.opponent).leaders.find(l => l.id === target)!.dead, false);
|
||||
assert.equal(player(g, f.opponent).leaders.filter(l => l.dead).length, 4);
|
||||
assert.equal(viewGame(g, f.owner).leaderSkills!.offer, null);
|
||||
assert.equal(viewGame(g, f.opponent).leaderSkills!.offer!.cards.length, 0);
|
||||
const event = viewGame(g, f.opponent).leaderSkills!.offer!.event;
|
||||
legalPolicies(g, f.opponent, after => {
|
||||
assert.equal(viewGame(after, f.opponent).leaderSkills!.offer!.cards.length, 2);
|
||||
assert.equal(player(after, f.opponent).spice, player(g, f.opponent).spice);
|
||||
});
|
||||
g = step(g, { actor: f.opponent, action: { type: 'leaderSkill', event, mode: 'draw' } }, f.actions);
|
||||
const offer = viewGame(g, f.opponent).leaderSkills!, skill = offer.offer!.cards.find(s => !offer.unavailableSkills?.[s]); assert.ok(skill);
|
||||
reject(g, f.owner, { type: 'leaderSkill', event, skill, leader: target });
|
||||
reject(g, f.opponent, { type: 'leaderSkill', event, skill, leader: DUKE_VIDAL_ID });
|
||||
g = step(g, { actor: f.opponent, action: { type: 'leaderSkill', event, skill, leader: target } }, f.actions);
|
||||
assert.equal(g.leaderSkills!.assignments.find(a => a.owner === f.opponent)!.leader, target);
|
||||
assert.equal(player(g, f.opponent).spice, player(before, f.opponent).spice - dead.strength);
|
||||
assert.equal(g.nexusCards!.cards!.hands[f.owner], 'moritani'); custody(g);
|
||||
});
|
||||
|
||||
void test('Basic native Moritani loss keeps normal same-home Suk but never gains Advanced private assassination', () => {
|
||||
const f = closing({ faction: 'moritani', advanced: false, discovery: false, tech: true });
|
||||
const disc = player(f.afterSetup, f.owner).leaders.find(l => l.id !== f.trainer)!.id;
|
||||
const battle = deathBattle(f, f.game, disc, true), revealed = reveal(battle.game, battle.plans, f.actions);
|
||||
const done = finishSettlement(revealed, f.actions);
|
||||
assert.equal(done.lastBattleContext!.winner, f.opponent);
|
||||
assert.equal(done.lastBattleContext!.sukRescue!.completed, true);
|
||||
assert.deepEqual(done.lastBattleContext!.sukRescue!.physical!.saved, { normal: 1, elite: 0 });
|
||||
assert.equal(pool(done, f.opponent, 'homeworld:guild').normal, pool(revealed, f.opponent, 'homeworld:guild').normal);
|
||||
const target = done.leaderSkills!.assignments.find(a => a.owner === f.opponent)!.leader;
|
||||
assert.equal(player(done, f.opponent).leaders.find(l => l.id === target)!.dead, false);
|
||||
assert.equal(done.leaderSkills!.assignments.find(a => a.leader === target)!.skill, 'suk-graduate');
|
||||
custody(done);
|
||||
});
|
||||
|
||||
for (const faction of ['ecaz', 'moritani'] as const) void test(`${faction} original Discovery shipment/reveal/free nested entry keeps all native reserve identity while all12 Nexus stay undealt`, () => {
|
||||
const f = discovery({ faction, advanced: false, roster: [faction, 'guild', 'emperor', 'beneGesserit'], tech: true });
|
||||
assert.equal(player(f.afterShipment, f.owner).reserves, player(f.beforeShipment, f.owner).reserves - 2);
|
||||
assert.ok(player(f.afterShipment, f.owner).spice < player(f.beforeShipment, f.owner).spice);
|
||||
assert.equal(pool(f.afterShipment, f.owner, `homeworld:${faction}`).normal, player(f.afterShipment, f.owner).reserves);
|
||||
assert.equal(f.revealed.discoveries!.tokens.find(t => t.id === f.token)!.revealedTurn, 1);
|
||||
assert.equal(player(f.entry.before, f.owner).forces['gara_kulon:8'], 2);
|
||||
assert.equal(player(f.entry.after, f.owner).forces['gara_kulon:8'] ?? 0, 0);
|
||||
assert.equal(player(f.entry.after, f.owner).forces['cistern:0'], 2);
|
||||
assert.equal(player(f.entry.after, f.owner).spice, player(f.entry.before, f.owner).spice);
|
||||
assert.equal(player(f.entry.after, f.owner).reserves, player(f.entry.before, f.owner).reserves);
|
||||
assert.deepEqual(f.entry.after.homeworlds!.custody, f.entry.before.homeworlds!.custody);
|
||||
assert.ok(Object.values(f.entry.after.nexusCards!.cards!.hands).every(c => c === null));
|
||||
const action = discoveryEntryMoveAction(viewGame(f.entry.before, f.owner), viewGame(f.entry.before, f.owner).discoveryEntry!.sources); assert.ok(action);
|
||||
reject(f.entry.before, f.opponent, action);
|
||||
reject(f.entry.after, f.owner, action);
|
||||
legalPolicies(f.entry.before, f.owner, (after, offered) => {
|
||||
assert.equal(player(after, f.owner).spice, player(f.entry.before, f.owner).spice);
|
||||
assert.equal(player(after, f.owner).reserves, player(f.entry.before, f.owner).reserves);
|
||||
if (offered.groups) assert.equal(player(after, f.owner).forces['cistern:0'], 2);
|
||||
else assert.equal(player(after, f.owner).forces['gara_kulon:8'], 2);
|
||||
assert.notEqual(after.decision?.kind, 'discoveryEntry');
|
||||
});
|
||||
custody(f.entry.after);
|
||||
});
|
||||
|
||||
void test('original supplied offers keep human choices while Duke training, Advanced Harkonnen and paired E3 reject before custody conversion', () => {
|
||||
const original = createGame('SUPPLIEDHWE3NX', newPlayer('native-original', 'Original native', 'ecaz'), false, ['ecaz']);
|
||||
joinGame(original, newPlayer('guild-original', 'Original Guild', 'guild'));
|
||||
const f = setup({ initial: original });
|
||||
const trained = f.afterSetup.leaderSkills!.assignments.find(a => a.owner === f.owner)!;
|
||||
const own = viewGame(f.offered, f.owner).leaderSkills!;
|
||||
assert.ok(own.offer!.cards.includes(trained.skill));
|
||||
assert.ok(own.eligibleLeaders.some(l => l.id === trained.leader));
|
||||
reject(f.offered, f.owner, { type: 'leaderSkill', event: own.offer!.event, skill: trained.skill, leader: DUKE_VIDAL_ID });
|
||||
const unsupported = own.offer!.cards.includes('mentat') ? 'warmaster' : 'mentat';
|
||||
if (!own.offer!.cards.includes(unsupported)) reject(f.offered, f.owner,
|
||||
{ type: 'leaderSkill', event: own.offer!.event, skill: unsupported, leader: trained.leader });
|
||||
legalPolicies(f.offered, f.owner, after => {
|
||||
const assignment = after.leaderSkills!.assignments.find(a => a.owner === f.owner); assert.ok(assignment);
|
||||
assert.ok(own.offer!.cards.includes(assignment.skill));
|
||||
assert.ok(player(after, f.owner).leaders.some(l => l.id === assignment.leader));
|
||||
assert.notEqual(assignment.leader, DUKE_VIDAL_ID);
|
||||
});
|
||||
const resumed = setup({ initial: reload(f.offered) });
|
||||
const actual = resumed.afterSetup.leaderSkills!.assignments.find(a => a.owner === f.owner)!;
|
||||
assert.ok(own.offer!.cards.includes(actual.skill));
|
||||
assert.equal(player(resumed.afterSetup, f.owner).reserves, 14);
|
||||
assert.equal(pool(resumed.afterSetup, f.owner, 'homeworld:ecaz').normal, 14); custody(resumed.afterSetup);
|
||||
assert.throws(() => setup({ initial: f.afterSetup }), /lobby|ORIGINAL|setup/);
|
||||
for (const [advanced, factions] of [
|
||||
[true, ['ecaz', 'guild', 'harkonnen']],
|
||||
[true, ['moritani', 'guild', 'harkonnen']],
|
||||
[true, ['ecaz', 'guild', 'moritani']],
|
||||
] as const) {
|
||||
let g = createGame('ORIGINALSTANDALONEGUARD', newPlayer(factions[0], factions[0], factions[0]), advanced, ['ecaz']);
|
||||
for (const faction of factions.slice(1)) joinGame(g, newPlayer(faction, faction, faction));
|
||||
g = applyAction(g, g.host, { type: 'homeworlds', enabled: true });
|
||||
g.nexusCards = { cards: null, phase: null };
|
||||
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
||||
const before = structuredClone(g);
|
||||
assert.throws(() => initializeLeaderSkillsGameForAudit(g)); assert.deepEqual(g, before);
|
||||
assert.equal(g.homeworlds!.custody, null);
|
||||
}
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { MOBILE_STRONGHOLD, territory } from '../game/board';
|
||||
import { quoteSpiceCollection } from '../game/board-resolution-quote';
|
||||
@@ -267,18 +267,3 @@ void test('representative native bot Face Dance consumes only actual paired rese
|
||||
custody(after, f.afterSetup);
|
||||
});
|
||||
|
||||
void test('original authenticated lobby and undealt CLI setup continue to actor-bound real Nexus Cunning without redealing', () => {
|
||||
const lobby = createGame('PAIREDIXAUTH', newPlayer('authenticated-ix', 'Ix', 'ixians'), true, ['ix']);
|
||||
for (const [id, faction] of [['authenticated-tl', 'tleilaxu'], ['authenticated-guild', 'guild'], ['authenticated-emperor', 'emperor']] as const)
|
||||
joinGame(lobby, newPlayer(id, faction, faction));
|
||||
const first = createPairedIxNexusModulesFixture({ initial: lobby, tech: true, strongholds: false });
|
||||
const continued = createPairedIxNexusModulesFixture({ initial: first.initial });
|
||||
assert.deepEqual(player(continued.afterSetup, first.ixians).hand, player(first.afterSetup, first.ixians).hand,
|
||||
'The original Ix setup offer and actual first deal are continued, not replaced');
|
||||
const empowered = useSuboids(continued);
|
||||
const revealed = revealPairedIxNexusModulesBattle(empowered, continued.planActions);
|
||||
assert.equal(quoteStrongholdFactionsBattle(revealed).winner, 'authenticated-ix');
|
||||
assert.equal(revealed.nexusSuboidHistory![0].owner, 'authenticated-ix');
|
||||
assert.equal(player(revealed, 'authenticated-tl').ally, null);
|
||||
custody(revealed, continued.afterSetup);
|
||||
});
|
||||
+46
-20
@@ -65,6 +65,19 @@ const HOMEWORLD_NEXUS_MODULE_PROFILES = [
|
||||
'homeworld-nexus-discovery-skills-stronghold', 'homeworld-nexus-discovery-skills-stronghold-tech',
|
||||
'homeworld-nexus-tech', 'homeworld-nexus-stronghold', 'homeworld-nexus-stronghold-tech',
|
||||
] as const;
|
||||
const HOMEWORLD_NATIVE_NEXUS_MODULE_PROFILES = [
|
||||
'paired-homeworld-nexus-skills', 'paired-homeworld-nexus-skills-tech',
|
||||
'paired-homeworld-nexus-skills-stronghold', 'paired-homeworld-nexus-skills-stronghold-tech',
|
||||
'paired-homeworld-nexus-discovery', 'paired-homeworld-nexus-discovery-tech',
|
||||
'paired-homeworld-nexus-discovery-stronghold', 'paired-homeworld-nexus-discovery-stronghold-tech',
|
||||
'paired-homeworld-nexus-discovery-skills', 'paired-homeworld-nexus-discovery-skills-tech',
|
||||
'paired-homeworld-nexus-discovery-skills-stronghold', 'paired-homeworld-nexus-discovery-skills-stronghold-tech',
|
||||
'paired-homeworld-nexus-tech', 'paired-homeworld-nexus-stronghold', 'paired-homeworld-nexus-stronghold-tech',
|
||||
'e3-homeworld-nexus-skills', 'e3-homeworld-nexus-skills-tech',
|
||||
'e3-homeworld-nexus-skills-stronghold', 'e3-homeworld-nexus-skills-stronghold-tech',
|
||||
'e3-homeworld-nexus-discovery-skills', 'e3-homeworld-nexus-discovery-skills-tech',
|
||||
'e3-homeworld-nexus-discovery-skills-stronghold', 'e3-homeworld-nexus-discovery-skills-stronghold-tech',
|
||||
] as const;
|
||||
type Profile = 'base' | 'choam' | 'ecaz' | 'ecaz-treachery' | 'ecaz-occupy' | 'moritani-assassinate' | 'stronghold-factions' | 'combined' | 'combined-nexus' | 'combined-homeworld-nexus' | 'homeworld' | 'nexus' | 'homeworld-nexus' | 'choam-roster' | 'ecaz-roster' | 'ix-roster' | 'choam-nexus' | 'ecaz-nexus' | 'ix-nexus' | 'moritani-skills' | 'tleilaxu-skills' | 'ix-skills' | 'choam-skills' | 'richese-skills' | 'ecaz-skills' | 'skills-tech' | 'skills-stronghold' | 'skills-stronghold-tech'
|
||||
| 'factions-tech' | 'stronghold-factions-tech' | 'nexus-tech' | 'nexus-stronghold' | 'nexus-stronghold-tech'
|
||||
| 'paired-nexus-tech' | 'paired-nexus-stronghold' | 'paired-nexus-stronghold-tech' | 'nexus-skills' | 'paired-nexus-skills'
|
||||
@@ -80,7 +93,8 @@ type Profile = 'base' | 'choam' | 'ecaz' | 'ecaz-treachery' | 'ecaz-occupy' | 'm
|
||||
| 'paired-discovery-nexus-skills' | 'paired-discovery-nexus-skills-tech' | 'paired-discovery-nexus-skills-stronghold' | 'paired-discovery-nexus-skills-stronghold-tech'
|
||||
| 'e3-discovery-nexus-skills' | 'e3-discovery-nexus-skills-tech' | 'e3-discovery-nexus-skills-stronghold' | 'e3-discovery-nexus-skills-stronghold-tech'
|
||||
| (typeof HOMEWORLD_MODULE_PROFILES)[number]
|
||||
| (typeof HOMEWORLD_NEXUS_MODULE_PROFILES)[number];
|
||||
| (typeof HOMEWORLD_NEXUS_MODULE_PROFILES)[number]
|
||||
| (typeof HOMEWORLD_NATIVE_NEXUS_MODULE_PROFILES)[number];
|
||||
const discoveryStrongholdProfile = (profile: string) => profile === 'discovery-stronghold' ||
|
||||
profile === 'discovery-stronghold-tech' || profile === 'discovery-skills-stronghold' ||
|
||||
profile === 'discovery-skills-stronghold-tech' || profile === 'native-discovery-skills-stronghold' ||
|
||||
@@ -103,8 +117,10 @@ const nativeDiscoveryNexusSkillsProfile = (profile: string) =>
|
||||
profile === 'paired-discovery-nexus-skills-stronghold' || profile === 'paired-discovery-nexus-skills-stronghold-tech' ||
|
||||
profile === 'e3-discovery-nexus-skills' || profile === 'e3-discovery-nexus-skills-tech' ||
|
||||
profile === 'e3-discovery-nexus-skills-stronghold' || profile === 'e3-discovery-nexus-skills-stronghold-tech';
|
||||
const nativeHomeworldNexusSampleProfile = (profile: string) =>
|
||||
(HOMEWORLD_NATIVE_NEXUS_MODULE_PROFILES as readonly string[]).includes(profile);
|
||||
const homeworldNexusSampleProfile = (profile: string) =>
|
||||
(HOMEWORLD_NEXUS_MODULE_PROFILES as readonly string[]).includes(profile);
|
||||
(HOMEWORLD_NEXUS_MODULE_PROFILES as readonly string[]).includes(profile) || nativeHomeworldNexusSampleProfile(profile);
|
||||
const homeworldModuleSampleProfile = (profile: string) =>
|
||||
(HOMEWORLD_MODULE_PROFILES as readonly string[]).includes(profile) || homeworldNexusSampleProfile(profile);
|
||||
const homeworldSkillsSampleProfile = (profile: string) =>
|
||||
@@ -536,6 +552,12 @@ const NATIVE_DISCOVERY_NEXUS_SKILLS_SCENARIOS: readonly Scenario[] =
|
||||
.filter(scenario => (!profile.includes('stronghold') || scenario.rules === 'advanced') &&
|
||||
(!profile.endsWith('tech') || scenario.roster.length >= 3))
|
||||
.map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 5600 + band * 100 + index })));
|
||||
const HOMEWORLD_NATIVE_NEXUS_MODULE_SCENARIOS: readonly Scenario[] =
|
||||
HOMEWORLD_NATIVE_NEXUS_MODULE_PROFILES.flatMap((profile, band) =>
|
||||
(profile.startsWith('paired-') ? PAIRED_NEXUS_SKILLS_SCENARIOS : E3_DISCOVERY_NEXUS_SKILLS_BASE_SCENARIOS)
|
||||
.filter(scenario => (!homeworldStrongholdSampleProfile(profile) || scenario.rules === 'advanced') &&
|
||||
(!homeworldTechSampleProfile(profile) || scenario.roster.length >= 3))
|
||||
.map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 10300 + band * 100 + index })));
|
||||
const STRONGHOLD_FACTIONS_ROSTER: readonly FactionId[] = [
|
||||
'ixians', 'choam', 'emperor', 'fremen', 'harkonnen', 'beneGesserit',
|
||||
];
|
||||
@@ -688,6 +710,7 @@ function usage() {
|
||||
+ '\nHomeworld-skills, homeworld-discovery and homeworld-discovery-skills use original classic/base33 rosters in Basic/Advanced2..6, all14 offered training when Skills is selected and Discovery7/8 when Discovery is selected. Each family may add -tech (original Tech3+), -stronghold (Advanced2+) or -stronghold-tech (Advanced3+). Original setup/cards/native force sources, physical rescue, Maker/entry, phase-end industry and source-local held effects remain. No Nexus, native families, public starts, save conversion or pending-rule changes.'
|
||||
+ '\nNative-homeworld-skills, native-homeworld-discovery and native-homeworld-discovery-skills retain each existing E1/E2 or standalone Ecaz/Moritani roster/deck predicate and original Homeworld pools, Basic/Advanced2..6. Optional -tech requires3+; -stronghold requiresAdvanced2+; -stronghold-tech requiresAdvanced3+. Original private starts, all14 when selected, DS7/8 when selected, physical Suk/residual Cyborg exchange, native aftermath and held/industry/reward timing remain. Nexus, E3pairs/mixed families, unrelated previews, public starts, save conversion and pending rulings remain separate.'
|
||||
+ '\nHomeworld-nexus-skills, homeworld-nexus-discovery and homeworld-nexus-discovery-skills compose original classic/base33/all12 with optional all14 training and Discovery7/8, Basic/Advanced2..6. Each family adds -tech at3+, -stronghold inAdvanced2+ or -stronghold-tech inAdvanced3+. Without Skills/Discovery, homeworld-nexus-tech, homeworld-nexus-stronghold and homeworld-nexus-stronghold-tech retain the same prerequisites. Original native Homeworld pools, real closing draws, printed revival deposits, physical Suk and phase-end industry/held rewards remain. Native families, unrelated previews, unresolved effects, public starts and save conversion stay guarded.'
|
||||
+ '\nPaired-homeworld-nexus-skills, paired-homeworld-nexus-discovery and paired-homeworld-nexus-discovery-skills retain original both-native E1 or E2 roster/deck contracts, Homeworlds/all12 and optional all14/DS7+8, Basic/Advanced2..6. Families add -tech at3+, -stronghold inAdvanced2+, -stronghold-tech inAdvanced3+. Paired-homeworld-nexus-tech/-stronghold/-stronghold-tech omit Skills/Discovery. E3-homeworld-nexus-skills and e3-homeworld-nexus-discovery-skills retain standalone Ecaz OR Moritani/classics, Ecaz33/all14/all12, with the same optional prerequisites and original Advanced Harkonnen/Duke/assassination guards. Original typed custody, real closing deals, physical rescue/substitution/marker invoices, phase-end industry and winner/held timing remain. Other native singles/mixed decks, no-Skills E3 Nexus modules, E3pairs/mixed families, pending effects, public starts and save conversion stay separate.'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -819,6 +842,8 @@ function freshGame(scenario: Scenario) {
|
||||
if (scenario.profile === 'paired-nexus-skills' || scenario.profile === 'nexus-skills' || scenario.profile === 'nexus' || nexusModuleProfile(scenario.profile) || scenario.profile === 'homeworld-nexus' || scenario.profile === 'choam-nexus' || scenario.profile === 'ecaz-nexus' || scenario.profile === 'ix-nexus' || scenario.profile === 'combined-nexus' || scenario.profile === 'combined-homeworld-nexus')
|
||||
game.nexusCards = { cards: null, phase: null };
|
||||
if (homeworldDiscoverySampleProfile(scenario.profile) || nativeDiscoverySkillsProfile(scenario.profile) || discoverySkillsProfile(scenario.profile) || discoveryNexusProfile(scenario.profile)) game.discoveryEnabled = true;
|
||||
if (nativeHomeworldNexusSampleProfile(scenario.profile) && !homeworldSkillsSampleProfile(scenario.profile))
|
||||
return initializePairedNexusGameForAudit(game);
|
||||
if (discoveryProfile(scenario.profile)) {
|
||||
game.discoveryEnabled = true;
|
||||
return homeworldNexusSampleProfile(scenario.profile)
|
||||
@@ -890,23 +915,23 @@ function resumedGame(path: string) {
|
||||
game.mentatQuestionPreview
|
||||
)
|
||||
throw new Error('--resume sample scenarios exclude unsupported optional modules.');
|
||||
const scenario = [...SCENARIOS, ...HOMEWORLD_CLASSIC_NEXUS_MODULE_SCENARIOS, ...COMBINED_NEXUS_SCENARIOS, ...COMBINED_HOMEWORLD_NEXUS_SCENARIOS, ...BASE_SCENARIOS, ...MODULE_SCENARIOS, ...EXPANSION_ROSTER_SCENARIOS, ...ECAZ_TREACHERY_SCENARIOS, ...ECAZ_OCCUPY_SCENARIOS, ...PAIRED_NEXUS_SCENARIOS, ...MORITANI_ASSASSINATE_SCENARIOS, ...STRONGHOLD_FACTIONS_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...IX_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS, ...RICHESE_SKILLS_SCENARIOS, ...ECAZ_SKILLS_SCENARIOS, ...SKILLS_TECH_SCENARIOS, ...SKILLS_STRONGHOLD_SCENARIOS, ...NATIVE_TECH_SCENARIOS, ...CLASSIC_NEXUS_MODULE_SCENARIOS, ...PAIRED_NEXUS_MODULE_SCENARIOS, ...NEXUS_SKILLS_SCENARIOS, ...PAIRED_NEXUS_SKILLS_SCENARIOS, ...NEXUS_SKILLS_MODULE_SCENARIOS, ...PAIRED_NEXUS_SKILLS_MODULE_SCENARIOS, ...E3_NEXUS_SKILLS_SCENARIOS, ...HOMEWORLD_OCCUPATION_SCENARIOS, ...DISCOVERY_SCENARIOS, ...DISCOVERY_SKILLS_SCENARIOS, ...DISCOVERY_NEXUS_SCENARIOS, ...DISCOVERY_STRONGHOLD_SCENARIOS, ...NATIVE_DISCOVERY_SKILLS_SCENARIOS, ...DISCOVERY_NEXUS_SKILLS_SCENARIOS, ...PAIRED_DISCOVERY_NEXUS_SCENARIOS, ...NATIVE_DISCOVERY_NEXUS_SKILLS_SCENARIOS, ...HOMEWORLD_CLASSIC_MODULE_SCENARIOS, ...HOMEWORLD_NATIVE_MODULE_SCENARIOS].find((candidate) =>
|
||||
(homeworldDiscoverySampleProfile(candidate.profile) || nativeDiscoverySkillsProfile(candidate.profile) || discoveryProfile(candidate.profile) || discoverySkillsProfile(candidate.profile) || discoveryNexusProfile(candidate.profile)) === discovery &&
|
||||
(homeworldModuleSampleProfile(candidate.profile) || candidate.profile === 'homeworld' || candidate.profile === 'homeworld-occupation' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.homeworlds &&
|
||||
(candidate.profile === 'paired-nexus-skills' || candidate.profile === 'nexus-skills' || candidate.profile === 'nexus' || nexusModuleProfile(candidate.profile) || candidate.profile === 'homeworld-nexus' || candidate.profile === 'choam-nexus' || candidate.profile === 'ecaz-nexus' || candidate.profile === 'ix-nexus' || candidate.profile === 'combined-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.nexusCards &&
|
||||
(candidate.profile === 'homeworld-occupation') === !!game.homeworldOccupationPreview &&
|
||||
skillProfile(candidate.profile) === !!game.leaderSkills &&
|
||||
(homeworldTechSampleProfile(candidate.profile) || discoveryTechProfile(candidate.profile) || candidate.profile === 'skills-tech' || candidate.profile === 'skills-stronghold-tech' || nativeTechProfile(candidate.profile) || nexusTechProfile(candidate.profile)) === !!game.techTokens &&
|
||||
(homeworldStrongholdSampleProfile(candidate.profile) || discoveryStrongholdProfile(candidate.profile) || nativeStrongholdProfile(candidate.profile) || strongholdSkillProfile(candidate.profile) || nexusStrongholdProfile(candidate.profile)) === !!game.strongholdCards &&
|
||||
(candidate.profile === 'ecaz-treachery' || candidate.ecazTreachery === true) === !!game.ecazTreachery &&
|
||||
assassinationScenario(candidate) === !!game.moritaniAssassinatePreview &&
|
||||
(candidate.profile === 'ecaz-occupy' || ((homeworldModuleSampleProfile(candidate.profile) || nativeDiscoveryNexusSkillsProfile(candidate.profile) || nativeDiscoverySkillsProfile(candidate.profile) || nativeTechProfile(candidate.profile) || discoveryProfile(candidate.profile)) && candidate.roster.includes('ecaz'))) === !!game.ecazOccupyPreview &&
|
||||
assassinationScenario(candidate) === !!game.moritaniAssassinate &&
|
||||
candidate.rules === (game.advanced ? 'advanced' : 'basic') &&
|
||||
JSON.stringify(candidate.expansions) ===
|
||||
JSON.stringify(game.expansions) &&
|
||||
JSON.stringify(candidate.roster) ===
|
||||
JSON.stringify(game.players.map((player) => player.faction)),);
|
||||
const scenario = [...SCENARIOS, ...HOMEWORLD_NATIVE_NEXUS_MODULE_SCENARIOS, ...HOMEWORLD_CLASSIC_NEXUS_MODULE_SCENARIOS, ...COMBINED_NEXUS_SCENARIOS, ...COMBINED_HOMEWORLD_NEXUS_SCENARIOS, ...BASE_SCENARIOS, ...MODULE_SCENARIOS, ...EXPANSION_ROSTER_SCENARIOS, ...ECAZ_TREACHERY_SCENARIOS, ...ECAZ_OCCUPY_SCENARIOS, ...PAIRED_NEXUS_SCENARIOS, ...MORITANI_ASSASSINATE_SCENARIOS, ...STRONGHOLD_FACTIONS_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...IX_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS, ...RICHESE_SKILLS_SCENARIOS, ...ECAZ_SKILLS_SCENARIOS, ...SKILLS_TECH_SCENARIOS, ...SKILLS_STRONGHOLD_SCENARIOS, ...NATIVE_TECH_SCENARIOS, ...CLASSIC_NEXUS_MODULE_SCENARIOS, ...PAIRED_NEXUS_MODULE_SCENARIOS, ...NEXUS_SKILLS_SCENARIOS, ...PAIRED_NEXUS_SKILLS_SCENARIOS, ...NEXUS_SKILLS_MODULE_SCENARIOS, ...PAIRED_NEXUS_SKILLS_MODULE_SCENARIOS, ...E3_NEXUS_SKILLS_SCENARIOS, ...HOMEWORLD_OCCUPATION_SCENARIOS, ...DISCOVERY_SCENARIOS, ...DISCOVERY_SKILLS_SCENARIOS, ...DISCOVERY_NEXUS_SCENARIOS, ...DISCOVERY_STRONGHOLD_SCENARIOS, ...NATIVE_DISCOVERY_SKILLS_SCENARIOS, ...DISCOVERY_NEXUS_SKILLS_SCENARIOS, ...PAIRED_DISCOVERY_NEXUS_SCENARIOS, ...NATIVE_DISCOVERY_NEXUS_SKILLS_SCENARIOS, ...HOMEWORLD_CLASSIC_MODULE_SCENARIOS, ...HOMEWORLD_NATIVE_MODULE_SCENARIOS].find((candidate) =>
|
||||
(homeworldDiscoverySampleProfile(candidate.profile) || nativeDiscoverySkillsProfile(candidate.profile) || discoveryProfile(candidate.profile) || discoverySkillsProfile(candidate.profile) || discoveryNexusProfile(candidate.profile)) === discovery &&
|
||||
(homeworldModuleSampleProfile(candidate.profile) || candidate.profile === 'homeworld' || candidate.profile === 'homeworld-occupation' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.homeworlds &&
|
||||
(candidate.profile === 'paired-nexus-skills' || candidate.profile === 'nexus-skills' || candidate.profile === 'nexus' || nexusModuleProfile(candidate.profile) || candidate.profile === 'homeworld-nexus' || candidate.profile === 'choam-nexus' || candidate.profile === 'ecaz-nexus' || candidate.profile === 'ix-nexus' || candidate.profile === 'combined-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.nexusCards &&
|
||||
(candidate.profile === 'homeworld-occupation') === !!game.homeworldOccupationPreview &&
|
||||
skillProfile(candidate.profile) === !!game.leaderSkills &&
|
||||
(homeworldTechSampleProfile(candidate.profile) || discoveryTechProfile(candidate.profile) || candidate.profile === 'skills-tech' || candidate.profile === 'skills-stronghold-tech' || nativeTechProfile(candidate.profile) || nexusTechProfile(candidate.profile)) === !!game.techTokens &&
|
||||
(homeworldStrongholdSampleProfile(candidate.profile) || discoveryStrongholdProfile(candidate.profile) || nativeStrongholdProfile(candidate.profile) || strongholdSkillProfile(candidate.profile) || nexusStrongholdProfile(candidate.profile)) === !!game.strongholdCards &&
|
||||
(candidate.profile === 'ecaz-treachery' || candidate.ecazTreachery === true) === !!game.ecazTreachery &&
|
||||
assassinationScenario(candidate) === !!game.moritaniAssassinatePreview &&
|
||||
(candidate.profile === 'ecaz-occupy' || ((homeworldModuleSampleProfile(candidate.profile) || nativeDiscoveryNexusSkillsProfile(candidate.profile) || nativeDiscoverySkillsProfile(candidate.profile) || nativeTechProfile(candidate.profile) || discoveryProfile(candidate.profile)) && candidate.roster.includes('ecaz'))) === !!game.ecazOccupyPreview &&
|
||||
assassinationScenario(candidate) === !!game.moritaniAssassinate &&
|
||||
candidate.rules === (game.advanced ? 'advanced' : 'basic') &&
|
||||
JSON.stringify(candidate.expansions) ===
|
||||
JSON.stringify(game.expansions) &&
|
||||
JSON.stringify(candidate.roster) ===
|
||||
JSON.stringify(game.players.map((player) => player.faction)),);
|
||||
if (!scenario)
|
||||
throw new Error(
|
||||
'--resume does not match a fixed base, module or expansion sample scenario.',
|
||||
@@ -1114,7 +1139,8 @@ async function main() {
|
||||
throw new Error('Discovery with Tech Tokens requires three through six players.');
|
||||
const resume = values.resume ? resumedGame(values.resume) : null;
|
||||
const requestedSamples = homeworldModuleSampleProfile(profile)
|
||||
? (homeworldNexusSampleProfile(profile) ? HOMEWORLD_CLASSIC_NEXUS_MODULE_SCENARIOS
|
||||
? (nativeHomeworldNexusSampleProfile(profile) ? HOMEWORLD_NATIVE_NEXUS_MODULE_SCENARIOS
|
||||
: homeworldNexusSampleProfile(profile) ? HOMEWORLD_CLASSIC_NEXUS_MODULE_SCENARIOS
|
||||
: profile.startsWith('native-') ? HOMEWORLD_NATIVE_MODULE_SCENARIOS : HOMEWORLD_CLASSIC_MODULE_SCENARIOS)
|
||||
: nativeDiscoveryNexusSkillsProfile(profile) ? NATIVE_DISCOVERY_NEXUS_SKILLS_SCENARIOS
|
||||
: discoveryNexusSkillsProfile(profile) ? DISCOVERY_NEXUS_SKILLS_SCENARIOS
|
||||
|
||||
@@ -32,9 +32,9 @@ async function main() {
|
||||
'\nLeader-skills and banker-income also preserve canonical unused Stronghold Cards in fresh Advanced classic or supported native two-through-six-seat lobbies. Optional Tech Tokens requires three-through-six seats. All14 original skills, decks/cache and actual end-Mentat Stronghold assignment remain. Basic Strongholds, mixed E3 families, other overlays, public activation and save conversion stay excluded.',
|
||||
'\nThese skill/module entries additionally preserve standalone Ecaz OR Moritani ecaz33/all14 profiles. Tech is Basic/Advanced three-through-six seats; Strongholds is Advanced two-through-six. Native Ecaz five-disc/Duke assignment and Advanced Harkonnen exclusion, plus original Advanced Moritani skill-first assassination/normal-call forfeiture, remain. E3 pairs, mixed families, allied Occupy skills and other overlays are not opened.',
|
||||
'\nWithout Skills, factions preserves unused original Tech Tokens in fresh 3–6-seat Basic/Advanced selected E1/E2 native or standalone Ecaz OR Moritani lobbies with classic opponents; stronghold-factions composes them with Advanced Stronghold Cards. Original physical inventories, first-Storm owners, selected Ecaz lead rewards, even-force Basic Occupy and non-Harkonnen Advanced Moritani assassination remain. Skills, unrelated overlays, paired/mixed E3 rosters and public starts remain separate.',
|
||||
'\nNexus preserves selected unused original Tech Tokens in classic/base33/all12 three-through-six-seat Basic/Advanced lobbies, and/or unused Stronghold Cards in Advanced two-through-six-seat lobbies; both requires3+. Genuine first-Storm/end-Mentat custody and closing Nexus deals remain. Borrowed tariff/force identity, original free-return ledgers, phase-end industry, held support and retention/winner rewards reuse existing handlers. Skills, Homeworlds in this composition, expansions, unrelated previews, unresolved effects and public starts remain separate.',
|
||||
'\nNexus preserves selected unused original Tech Tokens in classic/base33/all12 three-through-six-seat Basic/Advanced lobbies, and/or unused Stronghold Cards in Advanced two-through-six-seat lobbies; both requires3+. Existing Homeworlds may be retained. Genuine first-Storm/end-Mentat custody and closing Nexus deals remain. Borrowed tariff/force identity, original free-return ledgers, phase-end industry, held support and retention/winner rewards reuse existing handlers. Skills, expansion families outside their documented entries, unrelated previews, unresolved effects and public starts remain separate.',
|
||||
'\nNexus additionally routes a supported paired native E1 (Ixians+Tleilaxu) OR E2 (CHOAM+Richese) lobby with classics and one required deck through original paired setup when Tech and/or Advanced Strongholds is selected. Tech requires3–6 Basic/Advanced, Strongholds2–6 Advanced, both3–6 Advanced. All12 Nexus, original47/35 decks/cache, native setup and actual module custody remain. Mixed families, E3, Skills, other overlays, public starts and pending rulings stay separate.',
|
||||
'\nLeader-skills --nexus-cards explicitly adds all12 Nexus to fresh classic/base33 OR paired Ixians+Tleilaxu/Ix47 OR paired CHOAM+Richese/CHOAM35+cache setups with classics and all14 skills, two-through-six seats Basic/Advanced. Original starting hands/offers, skill/Traitor/native order and genuine closing deals remain. Mixed families/E3 pairs, HW, Banker/Mentat previews, borrowed Smuggler/pair-companion arithmetic, public starts and unresolved interactions stay separate.',
|
||||
'\nLeader-skills --nexus-cards explicitly adds all12 Nexus to fresh classic/base33 OR paired Ixians+Tleilaxu/Ix47 OR paired CHOAM+Richese/CHOAM35+cache setups with classics and all14 skills, two-through-six seats Basic/Advanced. Existing Homeworlds may be retained. Original starting hands/offers, skill/Traitor/native order and genuine closing deals remain. Mixed families/E3 pairs, Banker/Mentat previews, borrowed Smuggler/pair-companion arithmetic, public starts and unresolved interactions stay separate.',
|
||||
'\nThese classic or paired one-family skill-Nexus entries preserve selected unused original Tech Tokens at3–6 Basic/Advanced and/or Stronghold Cards at2–6 Advanced; both require3–6 Advanced. Real first-Storm/end-Mentat custody, phase-end industry, held subsidy, typed skill rescue/equal substitution, signed one-invoice marker pairs and mandatory original-winner Tech before Face Dance use original handlers. No mixed-family/native free-return accounting or pending rulings are opened.',
|
||||
'\nThe same fresh skill/Nexus entry admits standalone Ecaz OR Moritani with classics, exact ecaz33/all14/all12, Basic/Advanced2–6. Optional unused Tech requires3+; Strongholds requires Advanced; both3+ Advanced. Native Ecaz five-disc/Duke assignment and quiet living-Duke Cunning, plus original Advanced Moritani skill-first assassination/normal-traitor forfeiture, remain. Advanced Harkonnen, shared-Duke assignment, enhanced Terror relocation/HMS/Grumman and other pending effects stay guarded.',
|
||||
'\nIn the standalone Ecaz skill/Nexus profile, mandatory Advanced Occupy composes the chosen faction’s skill with separately labelled armies. Ecaz-led Suk rescues its own fixed casualties after the ally’s variable losses; Diplomat uses the chosen faction’s undialed own pool. Shared training, Basic odd rounding and other pending effects remain separate.',
|
||||
@@ -42,9 +42,9 @@ async function main() {
|
||||
'\nWhen native CHOAM is seated, this fresh entry additionally marks original occupied Tupile hand slots and owner-selected normal-limit cleanup. The occupier/reciprocal ally gain one validated slot through existing capacity consumers; Advanced departure and ally removal queue the original lost-holder cleanup. Private selected-ID discards preserve current phase/opening and remaining movement, with no new card or fee. Known occupation suppresses new native low-intelligence queries; proved Advanced expiry restores eligibility without resetting historical answers/usage. Basic unknown cases retain source guards. Unmarked played profiles are not upgraded.',
|
||||
'\nWhen native Ecaz is seated, this original fresh entry separately marks its printed high Homeworld alliance victory: current native seven-plus, one actual joint stronghold, two distinct other native factions with proved current occupied holdings. It adds no ordinary stronghold/Tech points, pays no reward and changes no Duke authority. Public progress remains prospective until original Mentat; qualifying members join original winners before BG prediction/final fallback. Unmarked played/public profiles remain unchanged.',
|
||||
'\nDiscovery opts fresh classic, selected E1/E2 native families or standalone Ecaz OR Moritani with classics into original Discovery setup in Basic/Advanced2–6, optionally retaining original Homeworlds, unused Tech3–6 and Advanced Strongholds2–6. Original decks/cache, HMS/Face Dancers, typed entry, revealed-only nested No-Fields, stash limits, end-Mentat claims and native outcomes remain. Advanced Moritani excludes Harkonnen. Skills/Nexus, paired/mixed E3, contested/shared-payer rulings, public starts and played-game conversion stay separate.',
|
||||
'\nLeader-skills --discoveries composes fresh supported classic or native original family decks/all14 Skills with Discovery7/8 at2–6 seats Basic/Advanced under each existing roster predicate. Original Homeworlds may be retained without Nexus, or with Nexus for classic factions only; Tech requires3–6 and Strongholds Advanced2–6. --nexus-cards retains all12 with classic factions, paired E1/E2 natives or standalone Ecaz OR Moritani with classics; native entries exclude Homeworlds. Original private card choices/training/hands, Maker/votes/rides, physical residual Cyborg exchange, native aftermath and cleanup/Tech/Face Dance retain their own timing. E3pairs/mixed families, independent variants, Banker/Mentat previews, shared Duke/borrowed Smuggler/pair companion/native returns and pending rulings remain separate.',
|
||||
'\nNexus --discoveries explicitly composes fresh classic/base33 OR both native Ixians+Tleilaxu/Ix47 OR CHOAM+Richese/CHOAM35+cache with all12 Nexus and original Discovery7/8 at2–6 seats Basic/Advanced. Classic entries may retain original Homeworlds; native entries may not. Unused Tech requires3+; optional Strongholds require Advanced; both3+. Actual Great Maker losses/votes/typed reserve rides, both Advanced piles, settled-alliance end-Spice closing deals and original held claims remain. Signed native two-marker nested shipments retain one printed invoice, current revealed destination and original reserve materialization. Native Skills with Homeworlds, E3/mixed families, mixed-module Fremen Betrayal, pending rulings, public activation and save conversion stay guarded.',
|
||||
'\nFresh supported classic or native E1/E2/standalone E3 lobbies with original Homeworlds support leader-skills, discovery or leader-skills --discoveries, Basic/Advanced2–6. Preserve unused Tech3+ and Advanced Strongholds2+; both requireAdvanced3+. Only classic entries additionally compose Nexus through leader-skills --nexus-cards with optional --discoveries, or nexus with optional --discoveries and original Tech/Strongholds. Native roster/deck/assassination/Duke boundaries remain. Original all14/DS7+8 when selected, private native starts, split Imperial and typed native/visitor/nested pools, real closing draws, printed native revival deposits, phase-end industry, held effects and winner rewards use original consumers. Native Homeworld/Nexus, unsupported E3pair/mixed rosters, unrelated previews, public starts and save conversion remain separate.',
|
||||
'\nLeader-skills --discoveries composes fresh supported classic or native original family decks/all14 Skills with Discovery7/8 at2–6 seats Basic/Advanced under each existing roster predicate. Original Homeworlds may be retained; adding --nexus-cards requires classic factions, both native Ixians+Tleilaxu OR CHOAM+Richese with one family deck, or standalone Ecaz OR Moritani with classics. Tech requires3–6 and Strongholds Advanced2–6. Original private card choices/training/hands, Maker/votes/rides, physical residual Cyborg exchange, native aftermath and cleanup/Tech/Face Dance retain their own timing. Other native singles/mixed decks, E3pairs/mixed families, independent variants, Banker/Mentat previews, shared Duke/borrowed Smuggler/pair companion/native returns and pending rulings remain separate.',
|
||||
'\nNexus --discoveries explicitly composes fresh classic/base33 OR both native Ixians+Tleilaxu/Ix47 OR CHOAM+Richese/CHOAM35+cache with all12 Nexus and original Discovery7/8 at2–6 seats Basic/Advanced, optionally retaining original Homeworlds. Unused Tech requires3+; optional Strongholds require Advanced; both3+. Actual Great Maker losses/votes/typed reserve rides, both Advanced piles, settled-alliance end-Spice closing deals and original held claims remain. Signed native two-marker nested shipments retain one printed invoice, current revealed destination and original reserve materialization. No-Skills E3/mixed families, mixed-module Fremen Betrayal, pending rulings, public activation and save conversion stay guarded.',
|
||||
'\nFresh supported classic or native E1/E2/standalone E3 lobbies with original Homeworlds support leader-skills, discovery or leader-skills --discoveries, Basic/Advanced2–6. Preserve unused Tech3+ and Advanced Strongholds2+; both requireAdvanced3+. Classic or paired E1/E2 entries additionally compose Nexus through original leader-skills --nexus-cards with optional --discoveries, or original nexus with optional --discoveries/Tech/Strongholds. Standalone Ecaz OR Moritani with classics additionally composes Homeworld/Nexus only with Skills in this bounded entry. Native roster/deck/assassination/Duke boundaries remain. Original all14/DS7+8 when selected, private native starts, split Imperial and typed native/visitor/nested pools, real closing draws, printed native revival deposits, phase-end industry, held effects and winner rewards use original consumers. Other native singles/mixed-deck Nexus, no-Skills E3 Nexus modules, unsupported E3pair/mixed rosters, unrelated previews, public starts and save conversion remain separate.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user