Compose original classic and paired Nexus Discovery modules
This commit is contained in:
1 parent
9e8c718739
commit
c8a6d0bb3c
20 files changed
+1603
-44
No files matched your search
@@ -17,6 +17,31 @@ open mode gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
**Classic Skills/Nexus/Discovery and paired native Nexus/Discovery — bounded
|
||||
Development Verified:** original fresh classic base33/all14/all12 training
|
||||
and separate paired E1/E2 no-Skills47/35/cache setup retain Discovery7/8,
|
||||
Basic/Advanced2–6, optional Tech3+ and Advanced Strongholds2+.
|
||||
Actual Maker losses/votes/typed rides, both piles, settled alliances and
|
||||
end-Spice closing deals use original controls. Signed native nested markers
|
||||
retain their one-invoice/current-revealed-site/physical-reserve contract.
|
||||
Other overlays, native Skills/Nexus/Discovery, pending rulings, public starts
|
||||
and played-save conversion stay gated.
|
||||
|
||||
Affected169, types/lint and build pass. All18 original six-seat samples complete:
|
||||
13,576 accepted/13,576 attempts/359 JSON on unchanged7c65011d working tree.
|
||||
Original human BT4SBSLB16/Charity retains natural training/Cistern3, six real
|
||||
Guild Maker losses and its original Fremen closing draw. BZNXHKMU19/Charity
|
||||
retains typed Shrine3/1Cyborg, two real Tleilaxu losses and native closing cards.
|
||||
RJF6D6ML24/Movement retains original setup, two CHOAM losses, native closing
|
||||
cards and actual signed No-Field5/3 Shrine8 settlement: Richese5→4/Guild3→4,
|
||||
reserves19→14→11, no second fee. All three actual rides use two ordinary/one
|
||||
Fedaykin free. Native undealt singleton ordering and controlled positions
|
||||
are explicit, not natural-history/every-effect or deployed acceptance.
|
||||
[Canonical combined evidence](DISCOVERY_PROTOTYPE.md#classic-skillsnexus-and-paired-native-nexus-discovery--6-october-2026).
|
||||
Final cached-constructor/live-guide union50, TypeScript/lint and final build
|
||||
pass; three final original programme re-runs complete1,659/1,659/44 JSON,
|
||||
not added to the18 unique profile/seed samples.
|
||||
|
||||
**Native Leader Skills / Discovery — bounded Development Verified:**
|
||||
fresh supported native Basic/Advanced family predicates retain original
|
||||
decks, starting hands/offers/all14, Discovery7/8, optional Tech3+ and Advanced
|
||||
|
||||
@@ -35,6 +35,83 @@ node --import tsx tools/start-prototype.ts --profile discovery --db PATH --room
|
||||
It preserves all seats and other rooms, requires the current version and refuses
|
||||
to redeal a started game. The default `ix` profile remains available.
|
||||
|
||||
## Classic Skills/Nexus and paired native Nexus Discovery — 6 October 2026
|
||||
|
||||
Fresh classic `leader-skills --nexus-cards --discoveries` composes base33,
|
||||
all14 original offered training, all12 Nexus and Discovery7/8. The separate
|
||||
original `nexus --discoveries` entry additionally admits both Ixians+Tleilaxu
|
||||
with Ix47, or both CHOAM+Richese with CHOAM35/cache, plus classic opponents,
|
||||
without Skills. Both entries support Basic/Advanced2–6, optional unused
|
||||
Tech3+ and Advanced Strongholds2+. Original actors, starting hands, offers,
|
||||
native setup and printed component ownership are retained. Native combined
|
||||
Skills/Nexus/Discovery, E3/mixed Nexus families, Homeworlds, variants/previews,
|
||||
public starts, played-game conversion and pending rulings remain separate.
|
||||
|
||||
`classicNexusLeaderSkillsProfile`, `pairedDiscoveryNexusProfile` and the
|
||||
existing original initializers own these exact envelopes. Ordinary skill
|
||||
quotes and source-clear Emperor/Fremen ledgers remain shared. Richese Cunning
|
||||
retains its original signed two-marker frame and one printed tariff; its
|
||||
current destination must still be a revealed room location. Neither a held
|
||||
Stronghold Card nor a selected marker grants a different nested site's benefit.
|
||||
|
||||
Actual end-Mentat claims and next-turn free entry precede Storm. Actual Great
|
||||
Maker losses, storm-order votes and typed Fremen reserve rides precede settled
|
||||
alliances and the closing Nexus choice at the end of the **entire Spice Blow**,
|
||||
including both Advanced piles. No card is dealt at Maker reveal or Mentat.
|
||||
The classic programme also exercises actual offered training, distinct free
|
||||
revival quotas/Axlotl settlement, skilled nested battle cleanup and original
|
||||
winner Tech. Paired E1 retains typed losses and Tech before Face Dance; E2
|
||||
retains native declaration, private caps, CHOAM invoice and physical Cunning fuel.
|
||||
|
||||
Semantic evidence: `discovery-classic-nexus-skills-runtime.test.ts`,
|
||||
`discovery-paired-nexus-runtime.test.ts` and their original fixture programmes.
|
||||
Affected union **169/169** across14 files and types/lint pass; build passes.
|
||||
The failed Advanced fixture had tried to reuse its already-discarded first-turn
|
||||
Rock Outcroppings. It now conserves an actually unplayed second printed land,
|
||||
without rewriting discard history. The native policy response uses the actual
|
||||
eligible Karama holder, not the generic fixture's unrelated first actor.
|
||||
Owning `LeaderSkillsView` supplies visibility; no concrete `ReturnType` contract.
|
||||
|
||||
Eight distinct new sample profiles use new4800–5500 bands and full roster/
|
||||
expansion names, preserving old profiles/defaults/seeds:
|
||||
`discovery-nexus-skills`, `discovery-nexus-skills-tech`,
|
||||
`discovery-nexus-skills-stronghold`, `discovery-nexus-skills-stronghold-tech`,
|
||||
`paired-discovery-nexus`, `paired-discovery-nexus-tech`,
|
||||
`paired-discovery-nexus-stronghold`, `paired-discovery-nexus-stronghold-tech`.
|
||||
All **18** original six-seat samples complete: **13,576 accepted/13,576 attempts/
|
||||
359 JSON round trips**, unchanged working tree
|
||||
`7c65011da5664444d85e873d85a4a1fd06bb5f880db7038a678fe7b90289ef8a`.
|
||||
This is genuine original setup/continuation, not a natural-every-effect,
|
||||
complete-faction, strategic-AI or recovery certificate.
|
||||
|
||||
Three new isolated human rooms preserve their original CLI setups:
|
||||
BT4SBSLB8→9 entry programme retains natural Prana-Bindu Adept, Suk Graduate
|
||||
and Sandmaster; human Cistern3 entry10, real Guild Maker losses6/vote11,
|
||||
human vote12/typed Fremen ride14, settled-alliance closing15 and human
|
||||
original Fremen draw16/Charity, deck12→11. BZNXHKMU10→11 retains original
|
||||
native setup: human Shrine2ordinary/1Cyborg entry12, actual Tleilaxu losses2,
|
||||
human vote14/typed ride16, closing17/actual native choices18–19/Charity.
|
||||
RJF6D6ML10→11 retains its original native starting hands: human Guild
|
||||
Shrine3 entry12, actual CHOAM losses2, vote14/typed ride16 and native closing
|
||||
choices18–19. Both native closing programmes explicitly reorder only their
|
||||
two **unplayed physical** singletons before actual draws; no held card is
|
||||
injected and this is not natural draw history.
|
||||
|
||||
The E2 original continuation20 reaches real Richese timing. Human signed
|
||||
No-Field5/3 shipment into revealed Shrine21, Guild allowance22 and the actual
|
||||
Karama holder's allowance23 settle one invoice: Richese5→4, Guild3→4,
|
||||
five real reserves materialize. Native reveal24 materializes the other three,
|
||||
reserves19→14→11, Shrine8 and no second fee. All three human typed rides move
|
||||
two ordinary/one Fedaykin to Polar Sink free, reserves20→17.
|
||||
These are local working-tree controls, not protected live acceptance.
|
||||
|
||||
Final constructor predicate reuse and live-guide union **50/50**, TypeScript,
|
||||
repaired unused-import lint and final build pass. The three original Advanced
|
||||
four-component samples re-run unchanged after that cutover:1,659/1,659/44 JSON.
|
||||
They are duplicate profile/seed re-runs, not additional unique samples in18.
|
||||
The original starter help runs, and the new paired Discovery/Tech two-seat
|
||||
request rejects with its existing three-through-six-seat prerequisite.
|
||||
|
||||
## Native Leader Skills composition — 6 October 2026
|
||||
|
||||
Fresh explicit `leader-skills --discoveries` now preserves original native
|
||||
|
||||
@@ -1,5 +1,33 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 6 October 2026 — Classic Skills/Nexus and paired native Nexus Discovery
|
||||
|
||||
Fresh original classic base33/all14/all12 and separate paired E1/E2 no-Skills
|
||||
47/35/cache setups compose Discovery7/8, Basic/Advanced2–6, optional unused
|
||||
Tech3+/Advanced Strongholds2+. Exact existing family predicates, original
|
||||
constructors, actors/offers/starting hands and native setup remain.
|
||||
Source-clear revival quotas, training, typed aftermath and held effects stay
|
||||
shared. Richese Cunning keeps its signed one-tariff pair and current revealed
|
||||
destination. Native combined Skills/Nexus/Discovery, other overlays, public
|
||||
starts, conversion and unresolved rulings remain gated.
|
||||
|
||||
Affected169/14files, types/lint and build pass. Genuine original eighteen
|
||||
six-seat samples finish13,576/13,576/359 JSON on unchanged7c65011d tree.
|
||||
New4800–5500 sample bands/full roster names leave old profiles/seeds/defaults
|
||||
unchanged. Original human BT4SBSLB16/Charity, BZNXHKMU19/Charity and
|
||||
RJF6D6ML24/Movement use connected CLI setups and actual controls: training,
|
||||
free entry, Maker6/2/2 casualties, storm-order votes, two-ordinary/one-Fedaykin
|
||||
free rides, both Advanced piles, reciprocal alliance and end-Spice closing
|
||||
deals. Native unplayed singleton ordering is explicitly controlled, not a
|
||||
natural-history claim. E2 human signed5/3 nested pair settles one fee
|
||||
Richese5→4/Guild3→4, original reserves19→14→11 and Shrine8, no second fee.
|
||||
[Canonical scope/proof](DISCOVERY_PROTOTYPE.md#classic-skillsnexus-and-paired-native-nexus-discovery--6-october-2026).
|
||||
Final constructor predicate reuse/live-guide union50, TypeScript, repaired
|
||||
unused-import lint and final build pass. Three original final programme
|
||||
re-runs finish1,659/1,659/44 JSON; these duplicate profile/seeds are not
|
||||
added to18 original samples. CLI help and actual Tech two-seat rejection run.
|
||||
Protected deployment/full acceptance/comprehensive assurance remain due.
|
||||
|
||||
## 6 October 2026 — Original native Leader Skills and Discovery
|
||||
|
||||
One exact Discovery envelope feeds existing native family/roster predicates
|
||||
|
||||
@@ -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.
|
||||
|
||||
## Classic Nexus/Discovery composition — 6 October 2026
|
||||
|
||||
The fresh original classic `leader-skills --nexus-cards --discoveries` entry
|
||||
retains base33/all14 offered training/all12 Nexus/Discovery7/8,
|
||||
Basic/Advanced2–6, optional unused Tech3+ and Advanced Strongholds2+.
|
||||
Original actors, hands, training and physical phase handlers remain.
|
||||
Actual Great Maker losses/votes/typed reserve rides, both Advanced piles and
|
||||
settled alliance precede the original end-Spice closing choice, not Mentat.
|
||||
Source-clear Emperor/Fremen free-return ledgers, original Axlotl settlement,
|
||||
skilled nested battle and mandatory original-winner Tech reuse existing rules.
|
||||
Native Skills/Nexus/Discovery, HW/variants/previews/public starts/conversion
|
||||
and unresolved effects remain guarded.
|
||||
|
||||
[Canonical combined evidence](DISCOVERY_PROTOTYPE.md#classic-skillsnexus-and-paired-native-nexus-discovery--6-october-2026)
|
||||
records the169 affected union, eighteen genuine six-seat completions and three
|
||||
connected original human programmes. BT4SBSLB retains naturally offered
|
||||
Prana-Bindu Adept/Suk Graduate/Sandmaster, actual Cistern entry and six Guild
|
||||
Maker casualties through the human closing draw. The separate paired native
|
||||
Discovery/Nexus entries do not enable Skills. No complete all14/faction or
|
||||
protected deployment claim follows from this bounded composition.
|
||||
|
||||
## Original native Discovery composition — 6 October 2026
|
||||
|
||||
Fresh native `leader-skills --discoveries` composes each already-supported
|
||||
|
||||
@@ -2,6 +2,34 @@
|
||||
|
||||
Original source audit, 10 September 2026, with dated bounded runtime follow-ups below. This document establishes the printed component inventory, source protocol and implementation boundaries; none lifts expansion release gates. The ordinary worm-triggered alliance Nexus already in the engine is distinct from this optional card module.
|
||||
|
||||
## Original Discovery, classic Skills and paired natives — 6 October 2026
|
||||
|
||||
The existing original fresh classic skill/Nexus entry additionally accepts
|
||||
`--discoveries`: base33/all14 actual offered training/all12/Discovery7/8,
|
||||
Basic/Advanced2–6, optional unused Tech3+ and Advanced Strongholds2+.
|
||||
The separate original `nexus --discoveries` entry admits both native
|
||||
Ixians+Tleilaxu/Ix47 OR CHOAM+Richese/CHOAM35/cache with classics, without
|
||||
Skills and with the same optional component prerequisites. Actual original
|
||||
actors, first hands, offers, native setup and phase handlers remain.
|
||||
Native combined Skills/Nexus/Discovery, E3/mixed families, HW/variants/previews,
|
||||
public starts and played-save conversion are not opened.
|
||||
|
||||
This composes existing sourced effects; it adds no universal priority or
|
||||
new ruling. Original Maker losses/votes/typed reserve ride, both Advanced
|
||||
piles and settled alliance precede the actual **end-Spice** closing choice.
|
||||
Extra Emperor versus ordinary Fremen returns retain distinct quotas and
|
||||
original Axlotl activity. Training stays on physical discs through nested
|
||||
battle and cleanup/Tech. Native E1 typed losses and matching Face Dance
|
||||
retain original ordering. E2 signed two-marker Cunning keeps one printed
|
||||
tariff, original private caps/reserves and a currently revealed destination;
|
||||
no held card supplies another site's benefit or a free companion.
|
||||
|
||||
[Combined canonical contract/proof](DISCOVERY_PROTOTYPE.md#classic-skillsnexus-and-paired-native-nexus-discovery--6-october-2026)
|
||||
records169 affected tests, types/lint/build, eighteen genuine original
|
||||
six-seat completions and three connected actual human programmes.
|
||||
These are bounded local implementation proofs, not complete modes,
|
||||
natural-every-effect, strategic AI, recovery or protected deployment acceptance.
|
||||
|
||||
## Classic Discovery / original Tech and Strongholds — 5 October 2026
|
||||
|
||||
Fresh local `nexus --discoveries` now admits classic/base33/all12 Nexus and
|
||||
|
||||
@@ -234,9 +234,9 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP
|
||||
| Paired E1/E2 Nexus / Tech / Strongholds | Fresh both Ixians+Tleilaxu OR CHOAM+Richese plus classics with one original family deck/all12 Nexus preserves Tech3..6 Basic/Advanced and/or Strongholds2..6 Advanced, without Skills or other overlays. Original Cunning, typed Suboid/Cyborg loss and equal substitution, Face Dancer reward-before-replacement and draw-before-retirement, two physical No-Fields/one tariff, native industry at phase end and actual held/copy effects reuse original handlers. No mixed families, E3, new interpretations, public starts or played-game conversion. | [Scope, sources and bounded evidence](NEXUS_CARD_RULES.md#paired-e1e2-tech-and-stronghold-composition--4-october-2026), [Richese composition](NEXUS_RICHESE_RULES.md#paired-techstronghold-composition--4-october-2026) |
|
||||
| Classic Nexus / Skills / Tech / Strongholds | Fresh classic original skill/Nexus entry retains selected unused Tech3..6 Basic/Advanced and/or Strongholds2..6 Advanced. Original setup/closing deals/owner timing, distinct free returns and phase-end industry remain. Held subsidy changes actual payer debit, not committed support/Suk entitlement; physical rescue precedes card cleanup and mandatory winner token choice. Original once-per-phase Bureaucrat diversion composes player payments. Captured-training leader revival remains blocked; public Ghola/ordinary options now omit those illegal choices rather than strand legal AI passes. Paired native combined components, HW, other previews, public/save conversion and new rulings stay guarded. | [Combined source contract](NEXUS_CARD_RULES.md#classic-nexus-skills-and-original-techstrongholds--4-october-2026) |
|
||||
| Paired Nexus / Skills / Tech / Strongholds | Both Ixians+Tleilaxu OR CHOAM+Richese plus classics and one original family deck/all14/all12 preserve selected unused Tech3..6 Basic/Advanced and/or Strongholds2..6 Advanced through the original fresh skill/Nexus entry. Typed rescue precedes equal actual Cyborg Tank substitution, original card cleanup/mandatory winner Tech precedes Face Dance; held subsidy changes debit, not committed support, and reward/custody stay original winner. Signed marker pair/one invoice/native industry retains original timing. Bounded runtime/program/phone/genuine-game evidence does not certify full modules; mixed families/E3/HW/previews/companions/native Nexus free-return rulings/public start/save conversion stay guarded. | [Paired combined contract and evidence](NEXUS_CARD_RULES.md#paired-nexus-skills-and-original-techstrongholds--5-october-2026) |
|
||||
| Classic Nexus / Leader Skills | Fresh classic/base33/all14 Skills/all12 Nexus, Basic/Advanced2..6, explicit `leader-skills --nexus-cards`, no other overlays/previews. Original starting hands/offers/skill-before-Traitor setup and actual closing Nexus draw remain. Exact extra/ordinary free-return ledgers, real bank purchase and committed Traitor declarations preserve trained-disc custody. Temporary ordinary-as-Sardaukar roles compose actual skill posture/bonuses/support; matching Cunning casualty receipt stays pending through original Suk rescue and completes only after physical settlement. Source-guarded borrowed Smuggler arithmetic, Atreides Suk/KH and unresolved effects remain; no public start, retrofit or new ruling. | [Scope, sources and bounded evidence](NEXUS_CARD_RULES.md#classic-nexus-and-leader-skills-composition--4-october-2026), [Skill runtime](LEADER_SKILLS_RUNTIME.md#classic-nexus-composition--4-october-2026) |
|
||||
| Classic Nexus / Leader Skills | Fresh classic/base33/all14 Skills/all12 Nexus, Basic/Advanced2..6, explicit `leader-skills --nexus-cards`, optionally original Tech3+/Advanced Strongholds2+ and Discovery7/8 through `--discoveries`; HW/variants/unrelated previews stay separate. Original starting hands/offers/skill-before-Traitor setup and actual closing Nexus draw remain. Exact extra/ordinary free-return ledgers, real bank purchase and committed Traitor declarations preserve trained-disc custody. Temporary ordinary-as-Sardaukar roles compose actual skill posture/bonuses/support; matching Cunning casualty receipt stays pending through original Suk rescue and completes only after physical settlement. Source-guarded borrowed Smuggler arithmetic, Atreides Suk/KH and unresolved effects remain; no public start, retrofit or new ruling. | [Scope, sources and bounded evidence](NEXUS_CARD_RULES.md#original-discovery-classic-skills-and-paired-natives--6-october-2026), [Skill runtime](LEADER_SKILLS_RUNTIME.md#classic-nexusdiscovery-composition--6-october-2026) |
|
||||
| Paired E1/E2 Nexus / Leader Skills | Fresh both Ixians+Tleilaxu OR CHOAM+Richese plus classics, one original family deck/all14 Skills/all12 Nexus, Basic/Advanced2..6, explicit original skill entry. Actual native setup/offers/closing draws remain. Full unsupported Suboids compose typed Suk rescue before equal actual Tank substitution; Face Dance follows original winner outcomes, kills/returns the winning trainer skill once without another bounty, and partial Cunning retains ordinary Mentat rights. Richese pair uses its original signed two-token frame/one invoice/native response/Guild continuation, not single-marker skill proof; CHOAM printed fuel/Planetologist physical route remain separate. No other overlays, companion/mixed-plan/new ruling/public starts or retrofit. | [Scope, sources and bounded evidence](NEXUS_CARD_RULES.md#paired-e1e2-nexus-and-leader-skills-composition--4-october-2026), [Skill runtime](LEADER_SKILLS_RUNTIME.md#paired-e1e2-nexus-composition--4-october-2026) |
|
||||
| Classic Nexus / Discovery / original components | Fresh classic/base33/all12/Discovery7/8 Basic/Advanced2–6, original Tech3+/Advanced Strongholds2+, explicit `nexus --discoveries`. Original worm/vote/typed ride, next-turn entry, end-Mentat held custody and end-Spice closing draw after a Nexus and settled alliance remain. Extra/ordinary revival ledgers and borrowed Guild nested payment preserve native identity and physical outcomes. Skills/native/HW/variants/previews and unresolved mixed-module Fremen Betrayal remain gated; no new universal interruption priority. | [Composition contract and bounded evidence](DISCOVERY_PROTOTYPE.md#classic-nexus-composition--5-october-2026), [Nexus source contract](NEXUS_CARD_RULES.md#classic-discovery--original-tech-and-strongholds--5-october-2026) |
|
||||
| Nexus / Discovery / original components | Fresh classic/base33/all12/Discovery7/8 Basic/Advanced2–6 optionally retains all14 original Skills through `leader-skills --nexus-cards --discoveries`; separate `nexus --discoveries` additionally admits both native E1 Ixians+Tleilaxu/Ix47 OR E2 CHOAM+Richese/CHOAM35+cache without Skills. Optional unused Tech3+/Advanced Strongholds2+ retain original setup/claims. Actual Maker losses/votes/typed rides, both Advanced piles and settled alliance precede the end-Spice closing draw. Source-clear return ledgers, trained battle and signed two-marker one-invoice/current-revealed-site native materialization use original handlers. Native combined Skills/Nexus/Discovery, E3/mixed families, HW/variants/previews and unresolved mixed-module Fremen Betrayal remain gated; no new universal interruption priority or ruling. | [Combined contract and bounded evidence](DISCOVERY_PROTOTYPE.md#classic-skillsnexus-and-paired-native-nexus-discovery--6-october-2026), [Nexus source contract](NEXUS_CARD_RULES.md#classic-discovery--original-tech-and-strongholds--5-october-2026) |
|
||||
| Discovery / original Advanced Strongholds | Fresh original Discovery classic/selected E1/E2/standalone E3 and separate classic all14 Skills entry retain Strongholds6 Advanced2–6, optional Tech3–6 and original end-Mentat claims. Named held benefits or actual HMS copy do not inherit into nested locations; Jacurutu creates no seventh card. Live supported Ecaz Discovery feeds mandatory original coalition lead/armies/payment/aftermath rather than skipping legal battle. Native typed/marker, trained rescue and winner Tech retain their sources. Basic Strongholds, native Discovery Skills, HW/variants/previews/pending rules/public starts/save conversion remain separate. | [Bounded contract and original programmes](DISCOVERY_PROTOTYPE.md#original-advanced-stronghold-composition--5-october-2026), [mandatory Ecaz repair](ECAZ_OCCUPY_RULES.md#original-live-discovery-coalition--5-october-2026) |
|
||||
| Native Leader Skills / Discovery | Original supported native Basic/Advanced family/roster predicates retain selected native decks/hands/all14 and Discovery7/8, optional Tech3+/Advanced Strongholds2+. Typed rescue precedes actual Cyborg substitution; original cleanup/Tech precedes Face Dance. Native stash/invoice/green marker and trained assassination/own revival keep physical sources. Actual starting hands/offers are authoritative. Hidden-Smuggler legal participation uses the engine's projected support quote, not private-state profile reconstruction. Native Ecaz owner-labelled coalition remains; no shared-Duke training, new ruling, other overlays/public starts or conversion. | [Bounded native contract and original runtime/human evidence](DISCOVERY_PROTOTYPE.md#native-leader-skills-composition--6-october-2026), [Smuggler capture repair](SMUGGLER_BATTLE.md#native-discovery-legal-participation--6-october-2026) |
|
||||
| Discoveries | Preserve seven sourced Spice Cards/eight tokens, faction peeks and Collection choices. Fresh classic/native E1/E2/standalone E3 may retain original Tech3..6; separate fresh classic all14 Skills/Discovery/Tech uses original training, entry, movement, stash, rescue/rewards and Bureaucrat invoices. Native setup, revealed-only No-Fields and Moritani source histories remain. Advanced Orgiz uses the supplied p24 territory-Collection trigger; Basic stays provisional per deposit. Only Jacurutu adds a stronghold. Contested/shared/mixed cases, other combinations and public starts remain unfinished. | [Component authority](DISCOVERY_COMPONENTS.md), [native composition](DISCOVERY_PROTOTYPE.md#native-setup-and-original-consumers--5-october-2026), [classic Skills/Advanced source cutover](DISCOVERY_PROTOTYPE.md#classic-leader-skills-and-advanced-orgiz--5-october-2026) |
|
||||
|
||||
@@ -89,3 +89,33 @@ export function classicDiscoveryNexusProfile(game: FactionModuleProfile): boolea
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Both original natives in one E1/E2 Nexus/Discovery family, 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.ecazTreachery || game.semutaPreview || game.advancedPreview || game.kullPreview ||
|
||||
game.nexusKullPreview || game.guildBetrayalPreview || game.richeseBetrayalPreview ||
|
||||
game.nexusIxianReplacementPreview || game.nexusIxianBetrayalPreview ||
|
||||
game.nexusHarkonnenBetrayalPreview) return false;
|
||||
const expansion = game.expansions[0];
|
||||
if (expansion !== 'ix' && expansion !== 'choam') return false;
|
||||
const primary = expansion === 'ix' ? 'ixians' : 'choam';
|
||||
const secondary = expansion === 'ix' ? 'tleilaxu' : 'richese';
|
||||
let hasPrimary = false, hasSecondary = false;
|
||||
for (let i = 0; i < game.players.length; i++) {
|
||||
const player = game.players[i];
|
||||
for (let j = 0; j < i; j++) if (game.players[j].faction === player.faction) return false;
|
||||
let family: string | undefined;
|
||||
for (const faction of FACTIONS)
|
||||
if (faction.id === player.faction) { family = faction.expansion; break; }
|
||||
if (family !== 'base' && family !== expansion) return false;
|
||||
if (player.faction === primary) hasPrimary = true;
|
||||
if (player.faction === secondary) hasSecondary = true;
|
||||
}
|
||||
return hasPrimary && hasSecondary;
|
||||
}
|
||||
+22
-13
@@ -1,6 +1,6 @@
|
||||
import { classicNexusModulesProfile, pairedNexusModulesProfile } from './nexus-module-profile';
|
||||
import { nativeFactionTechProfile } from './faction-module-profile';
|
||||
import { classicDiscoveryLeaderSkillsProfile, classicDiscoveryNexusProfile, discoveryModeSupported, discoveryModuleProfile } from './discovery-module-profile';
|
||||
import { classicDiscoveryLeaderSkillsProfile, classicDiscoveryNexusProfile, discoveryModeSupported, discoveryModuleProfile, pairedDiscoveryNexusProfile } from './discovery-module-profile';
|
||||
import { advancedMoritaniLeaderSkillsProfile, advancedNativeLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, pairedNexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile } from './leader-skill-profile';
|
||||
import { bribeTimingBlock, maximumBribe, type BribeOptions } from './bribe-options';
|
||||
import { quoteSpicePlacement, stormExposesTerritory, stormSectorAfter, wormConsumesForces } from './disaster-rules';
|
||||
@@ -9321,11 +9321,17 @@ export function initializePairedNexusGameForAudit(state: Game): Game {
|
||||
state.players.some(player => player.faction === 'ixians') &&
|
||||
state.players.some(player => player.faction === 'tleilaxu'))),
|
||||
'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.discoveryEnabled && !state.ecazTreachery &&
|
||||
((!state.techTokens && !state.strongholdCards) || pairedNexusModulesProfile(state)),
|
||||
'Enable Nexus Cards with only supported unused paired E1/E2 Tech or Advanced Stronghold components.');
|
||||
return initializeSetupGameForAudit(state, false, true, false, false, false, false, true);
|
||||
!state.homeworlds && !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.');
|
||||
if (state.discoveryEnabled) {
|
||||
requireFreshBaseRuntime(state);
|
||||
requireFreshFactionInventory(state);
|
||||
}
|
||||
return initializeSetupGameForAudit(state, false, true, false, discovery, false, false, true);
|
||||
}
|
||||
|
||||
/** Genuine opt-in auction source interruption; ordinary/public starts stay gated. */
|
||||
@@ -9527,13 +9533,14 @@ export function initializeDiscoveryGameForAudit(state: Game): Game {
|
||||
export function initializeLeaderSkillsGameForAudit(state: Game): Game {
|
||||
requireRule(!state.leaderSkills, 'Leader Skills cannot redeal existing skill cards.');
|
||||
if (state.discoveryEnabled) {
|
||||
requireRule(classicDiscoveryLeaderSkillsProfile(state) || nativeDiscoveryLeaderSkillsProfile(state), 'Discovery Leader Skills require a fresh supported classic or native lobby with optional original Tech or Advanced Strongholds and no unrelated overlays.');
|
||||
requireRule(classicDiscoveryLeaderSkillsProfile(state) || nativeDiscoveryLeaderSkillsProfile(state) || classicNexusLeaderSkillsProfile(state), 'Discovery Leader Skills require a fresh supported classic or native lobby with optional original Nexus, Tech or Advanced Strongholds and no unrelated overlays.');
|
||||
requireFreshBaseRuntime(state);
|
||||
requireFreshFactionInventory(state);
|
||||
}
|
||||
const g = structuredClone(state);
|
||||
g.leaderSkills = createLeaderSkills(random);
|
||||
const initialized = initializeSetupGameForAudit(g, false, classicNexusLeaderSkillsProfile(g) || pairedNexusLeaderSkillsProfile(g) || standaloneE3NexusLeaderSkillsProfile(g), false, classicDiscoveryLeaderSkillsProfile(g) || nativeDiscoveryLeaderSkillsProfile(g), true,
|
||||
const classicNexus = classicNexusLeaderSkillsProfile(g);
|
||||
const initialized = initializeSetupGameForAudit(g, false, classicNexus || pairedNexusLeaderSkillsProfile(g) || standaloneE3NexusLeaderSkillsProfile(g), false, classicDiscoveryLeaderSkillsProfile(g) || nativeDiscoveryLeaderSkillsProfile(g) || (g.discoveryEnabled === true && classicNexus), true,
|
||||
g.expansions.length === 1 && g.expansions[0] === 'choam', nativeExpansionLeaderSkillsProfile(g));
|
||||
if (advancedMoritaniLeaderSkillsProfile(initialized)) initializeMoritaniAssassinateState(initialized);
|
||||
if (nativeDiscoveryLeaderSkillsProfile(initialized) && byFaction(initialized, 'ecaz')) initialized.ecazOccupyPreview = true;
|
||||
@@ -9574,7 +9581,8 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
|
||||
const skillTech = leaderSkills && (classicTechLeaderSkillsProfile(g) || nativeTechLeaderSkillsProfile(g) || skillStronghold || classicDiscoveryLeaderSkillsProfile(g));
|
||||
const nativeTech = factions && !leaderSkills && nativeFactionTechProfile(g);
|
||||
const discoveryComposition = discovery && (discoveryModuleProfile(g) ||
|
||||
classicDiscoveryNexusProfile(g) || (leaderSkills && (classicDiscoveryLeaderSkillsProfile(g) || nativeDiscoveryLeaderSkillsProfile(g))));
|
||||
classicDiscoveryNexusProfile(g) || pairedDiscoveryNexusProfile(g) ||
|
||||
(leaderSkills && (classicDiscoveryLeaderSkillsProfile(g) || nativeDiscoveryLeaderSkillsProfile(g) || classicNexusLeaderSkillsProfile(g))));
|
||||
const nexusModules = nexus && (classicNexusModulesProfile(g) || pairedNexusModulesProfile(g) ||
|
||||
(leaderSkills && (classicNexusLeaderSkillsProfile(g) || pairedNexusLeaderSkillsProfile(g) || standaloneE3NexusLeaderSkillsProfile(g))));
|
||||
requireRule(!g.techTokens || ((skillTech || nativeTech || nexusModules || discoveryComposition) &&
|
||||
@@ -25677,9 +25685,10 @@ const RICHESE_CUNNING_ROSTER: Partial<Record<FactionId, true>> = {
|
||||
beneGesserit: true, guild: true, richese: true, choam: true,
|
||||
};
|
||||
function richeseCunningModeSupported(g: Game) {
|
||||
return !g.homeworlds && (!g.leaderSkills || pairedNexusLeaderSkillsProfile(g)) && !g.discoveryEnabled &&
|
||||
!g.ecazTreachery && !g.sandtrout &&
|
||||
g.players.every(player => RICHESE_CUNNING_ROSTER[player.faction] === true);
|
||||
return pairedDiscoveryNexusProfile(g) ||
|
||||
(!g.homeworlds && (!g.leaderSkills || pairedNexusLeaderSkillsProfile(g)) && !g.discoveryEnabled &&
|
||||
!g.ecazTreachery && !g.sandtrout &&
|
||||
g.players.every(player => RICHESE_CUNNING_ROSTER[player.faction] === true));
|
||||
}
|
||||
function nexusRicheseCunningOffer(g: Game, owner: string) {
|
||||
const p = g.players.find(player => player.id === owner);
|
||||
@@ -25726,8 +25735,8 @@ function currentRichesePairShipment(g: Game, shipment: PendingShipment) {
|
||||
const cost = reserveShipmentCost({ faction: p.faction, halfRate: false },
|
||||
territory(shipment.territory).type, 1);
|
||||
requireRule(shipment.cost === cost && Number.isSafeInteger(shipment.sector) &&
|
||||
territory(shipment.territory).sectors.includes(shipment.sector),
|
||||
'Richese Cunning lost its original one-marker price or destination.');
|
||||
validGameLocation(g, shipment.territory, shipment.sector),
|
||||
'Richese Cunning lost its original one-marker price or currently revealed destination.');
|
||||
noFieldRule(() => validateRicheseNoFieldPair(p.noField!, {
|
||||
tokenId: shipment.noField!.tokenId,
|
||||
controller: p.id,
|
||||
|
||||
@@ -34,11 +34,12 @@ export function noOtherLeaderSkillModules(game: LeaderSkillProfile): boolean {
|
||||
!game.discoveryEnabled && !game.strongholdCards && !game.techTokens && !game.ecazTreachery;
|
||||
}
|
||||
|
||||
function nexusLeaderSkillModulesSupported(game: LeaderSkillProfile, assassination = false): 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.discoveries && !game.discoveryEnabled &&
|
||||
!game.discoveryStash && !game.greatMaker &&
|
||||
!game.homeworlds &&
|
||||
((discovery && game.discoveryEnabled === true) ||
|
||||
(!game.discoveries && !game.discoveryEnabled && !game.discoveryStash && !game.greatMaker)) &&
|
||||
(!game.techTokens || game.players.length >= 3) &&
|
||||
(!game.strongholdCards || game.advanced === true) &&
|
||||
!game.ecazTreachery && !game.semutaPreview &&
|
||||
@@ -52,7 +53,7 @@ function nexusLeaderSkillModulesSupported(game: LeaderSkillProfile, assassinatio
|
||||
|
||||
/** Fresh classic Nexus/Skills, with original Tech and Advanced Strongholds; native overlays stay separate. */
|
||||
export function classicNexusLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
|
||||
if (game.expansions.length || !nexusLeaderSkillModulesSupported(game)) return false;
|
||||
if (game.expansions.length || !nexusLeaderSkillModulesSupported(game, false, true)) return false;
|
||||
const players = game.players!;
|
||||
for (let i = 0; i < players.length; i++) {
|
||||
for (let j = 0; j < i; j++) if (players[j].faction === players[i].faction) return false;
|
||||
|
||||
@@ -66,7 +66,7 @@ export function emperorNexusModeSupported(g: Game): boolean {
|
||||
p.faction,
|
||||
),
|
||||
) &&
|
||||
(classicDiscoveryNexusProfile(g) ||
|
||||
(classicDiscoveryNexusProfile(g) || (!!g.leaderSkills && classicNexusLeaderSkillsProfile(g)) ||
|
||||
(!g.homeworlds &&
|
||||
(!g.leaderSkills || classicNexusLeaderSkillsProfile(g)) &&
|
||||
!g.discoveryEnabled &&
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FACTIONS } from './catalog';
|
||||
import type { FactionModuleProfile } from './faction-module-profile';
|
||||
import { classicDiscoveryNexusProfile } from './discovery-module-profile';
|
||||
import { classicDiscoveryNexusProfile, pairedDiscoveryNexusProfile } from './discovery-module-profile';
|
||||
|
||||
/** Public classic configuration only; the initializer checks undealt physical components. */
|
||||
export function classicNexusModulesProfile(game: FactionModuleProfile): boolean {
|
||||
@@ -28,6 +28,7 @@ export function classicNexusModulesProfile(game: FactionModuleProfile): boolean
|
||||
|
||||
/** Both original natives in one E1 or E2 family; 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 ||
|
||||
|
||||
+8
-8
@@ -4080,8 +4080,8 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
searchText: 'Hiereg Smuggler Jacurutu Sietch Cistern Ecological Testing Station Shrine Orgiz Processing Station Treachery Card Stash Spice Stash Ornithopter',
|
||||
steps: [
|
||||
'Fresh Discovery development setup supports classic, selected Ixian/Tleilaxu/CHOAM/Richese families, or standalone Ecaz or Moritani with classic opponents in Basic or Advanced, with seven extra Spice Cards and eight tokens. Original unused Tech Tokens may be selected with three through six seats; original Stronghold Cards require Advanced. Other modules, paired or mixed Ecaz/Moritani families and normal public starts stay gated.',
|
||||
'The fresh Leader Skills entry may explicitly add Discoveries with the original classic or supported native family decks, all fourteen skill components, optional original Tech at three or more seats and Advanced Stronghold Cards. Existing native roster and Duke/assassination/Harkonnen restrictions remain. Actual starting hands and offered training are preserved. Physical free entry, nested tariffs, stash capacity, typed rescue, native aftermath and printed invoices use original sources. Nexus, Homeworlds, independent variants and separate Banker/Mentat previews remain outside this composition.',
|
||||
'A separate fresh classic Nexus entry may explicitly add Discoveries, original Tech at three or more seats and Advanced Stronghold Cards. Original end-Mentat ownership and next-turn physical entry remain. Great Maker finishes its worm, vote and typed reserve ride before the ordinary Nexus settlement; cards are offered only at the end of the entire Spice Blow phase after a Nexus and settled alliance, not immediately or at Mentat. Source-clear extra or ordinary free revival and Guild-priced nested shipment retain native pieces, ledgers and actual battle rewards. Skills, native families, Homeworlds, unrelated previews and unresolved mixed-module Fremen Betrayal stay separate.',
|
||||
'The fresh Leader Skills entry may explicitly add Discoveries with the original classic or supported native family decks, all fourteen skill components, optional original Tech at three or more seats and Advanced Stronghold Cards. Classic setups additionally accept --nexus-cards, retaining all12 and the actual settled-alliance end-Spice closing deal after Great Maker and both Advanced piles. Existing native roster and Duke/assassination/Harkonnen restrictions remain. Actual starting hands and offered training are preserved. Physical free entry, nested tariffs, stash capacity, typed rescue, native aftermath and printed invoices use original sources. Native Skills/Nexus/Discovery, Homeworlds, independent variants and separate Banker/Mentat previews remain outside this composition.',
|
||||
'A separate fresh Nexus entry may explicitly add Discoveries to classic factions or both native Ixians/Tleilaxu or CHOAM/Richese with classics, one original family deck and no Skills. Original Tech requires three or more seats; Stronghold Cards require Advanced. End-Mentat ownership and next-turn physical entry remain. Great Maker finishes its losses, votes and typed reserve ride before ordinary Nexus settlement; cards are offered only at the end of the entire Spice Blow after both Advanced piles and settled alliance, not immediately or at Mentat. Source-clear return ledgers, Guild-priced nested shipment and signed native one-invoice marker pairs retain physical pieces and original capacities. E3/mixed families, native Skills/Nexus/Discovery, Homeworlds, unrelated previews and unresolved mixed-module Fremen Betrayal stay separate.',
|
||||
'Original Stronghold Cards are claimed only at end-Mentat. A held benefit applies in its named territory or the mobile stronghold through its actual declared copy; revealed Discovery locations do not inherit another territory’s defense or bank support. Jacurutu counts toward stronghold victory but creates no seventh Stronghold Card. Physical free entry, nested tariffs, trained or native force casualties, card cleanup and optional Tech reward retain their original sources.',
|
||||
'Great Maker first resolves the ordinary worm consequences. All players then vote in storm order on creating a Nexus. A strict majority is required; a tie adds no Nexus and an existing Nexus in the phase stays recorded. Fremen may then ride with actual reserve forces to one legal destination for free, without using ordinary shipment or movement. Storm and occupancy restrictions still apply.',
|
||||
'Each of the six Discovery territory cards destroys prior spice and forces in the named blow territory, including Fremen forces, before placing six new spice subject to the storm. It also places a random available face-down token of the printed type at the card’s separate token destination.',
|
||||
@@ -4101,7 +4101,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
checklist: [
|
||||
{
|
||||
area: 'Implementation', status: 'Partial',
|
||||
detail: 'Original classic and supported native Skills/Discovery, native Discovery without skills and separate classic Nexus compositions retain selected Tech/Advanced Strongholds. Entry, marker/tariffs, stash limits, trained/typed rescue, Cyborg substitution, cleanup/Tech/Face Dance and native leader outcomes use original consumers. Held benefits stay source-local; mandatory Ecaz coalition keeps owner-labelled armies. Smuggler policies use projected authoritative support. Advanced Orgiz aggregates actual territory Collection; Basic stays provisional per deposit. Pending sources remain guarded.',
|
||||
detail: 'Original classic Skills/Nexus/Discovery and separate paired E1/E2 no-Skills Nexus/Discovery retain selected Tech/Advanced Strongholds alongside existing original classic/native Discovery entries. Actual Maker votes/typed rides and settled-alliance closing deals, one-invoice signed markers, training, rescue, Cyborg substitution and cleanup/Tech/Face Dance use original consumers. Held benefits stay source-local; mandatory Ecaz coalition keeps owner-labelled armies. Smuggler policies use projected authoritative support. Advanced Orgiz aggregates actual territory Collection; Basic stays provisional per deposit. Pending sources and public starts remain guarded.',
|
||||
evidence: ['game/discovery-module-profile.ts', 'game/richese-no-field.ts', 'game/discoveries.ts', 'game/discovery-actions.ts', 'game/discovery-entry.ts', 'game/discovery-collection.ts', 'game/discovery-flight.ts', 'game/discovery-battle.ts', 'game/discovery-storm.ts', 'game/shrine.ts', 'game/great-maker.ts', 'game/board.ts', 'game/engine.ts'],
|
||||
},
|
||||
{
|
||||
@@ -4403,9 +4403,9 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Entering any alliance discards your held Nexus Card, including alliances made through an Ambassador or Terror token. An offer that has not been accepted does not discard it.',
|
||||
'Use Cunning for your own faction, Betrayal when another player controls the printed faction, and Secret Ally when that faction is absent. Used cards go to the separate Nexus discard pile. Each printed effect has its own timing.',
|
||||
'Fresh local classic Nexus tables may preserve original Tech Tokens in Basic/Advanced three-through-six-seat lobbies and/or Stronghold Cards in Advanced two-through-six-seat lobbies; selecting both requires three or more. Original base33/all12 setup, closing Nexus deals, first-Storm Tech and actual end-Mentat Stronghold custody remain. Skills, Homeworlds in this composition, native expansions, unrelated previews, public starts and saved-game conversion stay separate.',
|
||||
'Fresh classic nexus --discoveries also preserves those original Tech and Advanced Stronghold prerequisites. The original seven Discovery Spice Cards/eight tokens, protected nested locations, next-turn physical entry and Great Maker worm/vote/typed ride remain. The actual closing draw follows both Advanced piles and settled alliances. Source-clear revival quotas and borrowed Guild nested tariffs compose existing controls; unresolved mixed-module Fremen Betrayal and other unfinished effects stay gated. No public start or saved-game conversion is granted.',
|
||||
'The same fresh local Nexus entry also composes Tech and/or Advanced Strongholds with paired Ixians + Tleilaxu OR CHOAM + Richese and classic opponents, one original family deck and no Skills. Tech needs three through six seats; Strongholds needs two through six Advanced seats. Real closing draws, native typed forces, physical Face Dancers and No-Fields, original payments/rewards and held effects remain. Mixed families, E3, Homeworlds, unrelated previews, public starts and unresolved rulings are not opened.',
|
||||
'The separate fresh local leader-skills --nexus-cards entry combines classic base33/all14 Skills/all12 Nexus in Basic or Advanced, two through six seats. Selected original Tech Tokens require three or more seats; Stronghold Cards require Advanced. Original starting hands/offers, closing draws, first-Storm Tech and end-Mentat held custody remain. Extra/ordinary free-three returns earn original phase-end Axlotl without changing skill custody. Emperor Cunning composes skill posture, support and Suk rescue; Arrakeen reduces payer cost, not committed support or rescue eligibility. Winner cleanup precedes mandatory Tech reward. Other overlays and public starts remain guarded.',
|
||||
'Fresh nexus --discoveries preserves original Tech and Advanced Stronghold prerequisites with classic factions or both native Ixians/Tleilaxu or CHOAM/Richese and one family deck, without Skills. The original seven Discovery Spice Cards/eight tokens, protected nested locations, next-turn physical entry and Great Maker losses/votes/typed ride remain. The closing draw follows both Advanced piles and settled alliances. Source-clear revival quotas, borrowed Guild tariffs and signed native two-marker one-invoice shipments into currently revealed sites use original controls. E3/mixed families, native Skills/Nexus/Discovery, unresolved mixed-module Fremen Betrayal and other unfinished effects remain guarded; no public start or save conversion.',
|
||||
'The same fresh local Nexus entry also composes Tech and/or Advanced Strongholds with paired Ixians + Tleilaxu OR CHOAM + Richese and classic opponents, one original family deck and no Skills. Discovery may additionally be selected through --discoveries, including without Tech or Strongholds. Tech needs three through six seats; Strongholds needs two through six Advanced seats. Real closing draws, native typed forces, physical Face Dancers and No-Fields, original payments/rewards and held effects remain. Mixed families, E3, Homeworlds, unrelated previews, public starts and unresolved rulings are not opened.',
|
||||
'The separate fresh local leader-skills --nexus-cards entry combines classic base33/all14 Skills/all12 Nexus in Basic or Advanced, two through six seats, optionally adding original Discovery7/8 through --discoveries. Original Tech requires three or more seats; Stronghold Cards require Advanced. Starting hands/offers, actual end-Spice closing draws, first-Storm Tech and end-Mentat held custody remain. Extra/ordinary free-three returns earn original phase-end Axlotl without changing skill custody. Emperor Cunning composes skill posture, support and Suk rescue; Arrakeen reduces payer cost, not committed support or rescue eligibility. Winner cleanup precedes mandatory Tech reward. Native Skills/Nexus/Discovery, unrelated overlays and public starts remain guarded.',
|
||||
'The same fresh skill/Nexus entry supports both Ixians + Tleilaxu OR both CHOAM + Richese with classics and one original family deck/all14/all12, Basic/Advanced two through six seats. Selected original Tech requires three or more; Strongholds requires Advanced. Full Suboid Cunning composes typed skill rescue before equal actual Cyborg Tank substitution. Original held subsidy changes debit, not rescue support. Card cleanup and mandatory original-winner Tech precede Face Dance; a matching trainer dies and returns its skill once without another bounty, while held cards and tokens remain with the original winner. Partial Tleilaxu refresh preserves Mentat rights. Original signed Richese pair and CHOAM fuel/industry stay distinct. Mixed families, E3, companions/mixed plans, native free-return accounting and pending rulings stay guarded.',
|
||||
'A separate standalone Ecaz OR Moritani skill/Nexus entry uses exact ecaz33/all14/all12 with classics, Basic/Advanced two through six seats; original Tech requires3+, Strongholds requires Advanced. Actual closing draws, five ordinary training discs, quiet living/unclaimed Duke Cunning and original Advanced assassination/normal-Traitor forfeiture remain. Native support/rescue, held effects, original card cleanup and mandatory winner Tech use existing handlers. Advanced Harkonnen, E3 pairs/mixed families/HW/previews, Duke training/exceptional custody, enhanced Terror relocation/HMS/Grumman and other pending rulings stay guarded.',
|
||||
'Atreides Cunning adds a second, different opposing plan element after the first native answer. Both remain binding. Use it in your own battle before sealing your plan; Karama may cancel the extra attempt while preserving the first disclosure.',
|
||||
@@ -4443,7 +4443,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Fremen Betrayal has two bounded advance-declaration alternatives for an unallied holder in a classic Nexus table. Before the first Spice Blow or worm appearance, spend the physical card to prevent Fremen ordinary and Cunning worm rides for the turn; worm survival, destruction and Nexus still resolve. This clean timing is a product boundary, not an official post-appearance priority ruling. Instead, before Fremen move during Shipment and Movement, spend it to suppress their native two-territory advantage for that turn while ordinary movement and independent ornithopters remain. Only one alternative can be used even if the card is recycled later that turn. Owner controls show the currently available choice; combined modules and reactive post-worm timing remain gated.',
|
||||
'When Ecaz is absent, an unallied holder may spend its Nexus card at a quiet play boundary to ask whether any one player, including themself, holds one of the holder’s native faction leaders as an ordinary Traitor Card. The owner receives a private yes/no answer saved at the moment of use; no name or count is disclosed. Dead or captured native leaders count, but foreign Gholas, Duke Vidal, Cheap Hero and Face Dancers do not. Native-leader scope and private-only audience are explicit product interpretations, not publisher rulings; exceptional controlled-leader and public-disclosure combinations remain open.',
|
||||
'When Richese is absent, its Secret Ally replaces your ordinary reserve shipment with up to five physical forces charged as one. Select the explicit shipment checkbox; forces, elite identity, destination restrictions, Homeworld reserve sources and normal movement remain unchanged. Fremen remain free within their normal reinforcement range; Guild and an active Karama rate retain their existing tariff and payment routing. This is not a concealed No-Field or an extra shipment.',
|
||||
'Native Richese Cunning has a bounded two-No-Field shipment in genuine Richese/CHOAM Nexus games, including supported fresh Tech/Advanced Strongholds without Skills or the separate paired all14 Skills composition without other overlays. Choose distinct unused tokens: one reveals immediately into reserve-capped public forces, the other remains concealed; pay for one marker and spend the Nexus. The previous shipment token cannot be either selection; the concealed token becomes next last-used. The skill composition retains the original signed pair frame and native response/Guild continuation, not a single-marker skill proof or free companion. Karama or Guild special prevention preserves physical tokens and unpaid spice, with the ordinary stopped-shipment allowance policy. When Tech is actually selected, an accepted off-planet pair accrues Heighliners once at phase end. Other module combinations, mixed dialing and full faceup-component display remain gated.',
|
||||
'Native Richese Cunning has a bounded two-No-Field shipment in genuine Richese/CHOAM Nexus games, including supported fresh Discovery/Tech/Advanced Strongholds without Skills or the separate paired all14 Skills composition without Discovery. Choose distinct unused tokens: one reveals immediately into reserve-capped public forces, the other remains concealed; pay for one marker and spend the Nexus. The previous shipment token cannot be either selection; the concealed token becomes next last-used. Both compositions retain the original signed pair frame and native response/Guild continuation, not a single-marker proof or free companion. A nested destination must remain currently revealed. Karama or Guild prevention preserves physical tokens and unpaid spice, with the original stopped-shipment policy. When Tech is selected, an accepted off-planet pair accrues Heighliners once at phase end. Other module combinations, mixed dialing and full faceup-component display remain gated.',
|
||||
'Richese Betrayal has a separate opt-in auction preview. Before payment, an unallied rival may spend its physical Richese Nexus card to discard a public self-cache win without any purchase debit, or redirect another buyer’s cache/Black Market payment to the bank while preserving delivery and earned bonus. Every publicly possible holder acknowledges the same opportunity; private Use eligibility cannot determine public timing. Normal hidden self-purchases and private special-Karama acquisitions are not connected. See the dedicated guide for its existing local runtime/browser evidence and remaining release gates; that evidence does not verify Nexus Kull.',
|
||||
'Guild Cunning is declared when finishing your ordinary Shipment and Movement turn. It spends the Nexus and opens one Karama response. Allowance grants a second native shipment at normal Guild prices, including supported cross-shipment, return and Homeworld routes. Cancellation leaves the completed first turn intact. Only an unused Hajr move can follow the second shipment; no ordinary movement or third move is granted. The physical Ornithopter card combination remains unavailable. The separate guild-betrayal profile admits only original native shipment producers and explicitly blocks this second-shipment offer before Nexus spending; Cunning outside that profile is unchanged.',
|
||||
'With Guild absent, its Secret Ally is an explicit optional source for one ordinary shipment at Guild prices. It supports physical reserve shipment, cross-shipment and return to abstract reserves, plus half-price independently legal world-to-world shipment. Fremen may retain free reinforcement or choose the paid card route beyond its normal range, outside the storm. Native faction, typed sources and normal movement stay unchanged. Homeworld reserve return awaits a ruling; arbitrary foreign-world transport and concealed No-Fields are not granted.',
|
||||
@@ -4645,7 +4645,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Fresh Advanced profiles compose native Ixians, Tleilaxu, CHOAM and/or Richese with classic opponents and distinct required family decks. All fourteen skills and native starts remain. Auditor stays unskilled; cyborg casualties, original CHOAM support and Richese capped marker revelation remain physical. Foreign revival creates no new skill. Native Ixian Technology on Richese lots, own mixed No-Field, captured replacement, Advanced Atreides Suk/KH and unrelated overlays retain their guards.',
|
||||
'Fresh three-through-six-seat Basic/Advanced development tables may compose all fourteen Skills with original Tech Tokens: classic factions, supported E1/E2 native families, or standalone Ecaz/Moritani with the exact ecaz deck and original roster exclusions. Starting cards/cache, skill/Traitor choices and printed/first-Storm assignment stay genuine. Existing leader-skills/banker-income entry preserves unused selected tokens; mixed E3 families, other overlays, public activation and saved-game conversion remain separate.',
|
||||
'Fresh Advanced classic or supported native two-through-six-seat skill tables may retain six original Stronghold Cards, with Tech at three or more seats. Original end-Mentat/local held effects remain. Arrakeen changes actual payer cost, not rescue or Banker eligibility; native CHOAM income retains bank support. Physical defenses, normal disciplines, holder ties and original dial/Worthless earnings remain. Standalone Ecaz Duke and Moritani assassination compose original rules; mixed E3, allied Occupy skills, copied Diplomat defenses, active skill/Stone and other guards stay separate.',
|
||||
'Fresh classic Leader Skills may explicitly add Nexus through local leader-skills --nexus-cards, Basic/Advanced two through six players, retaining selected original Tech at three or more seats and/or Advanced Strongholds. Saved hands/offers remain authoritative; actual closing draws remain required. Free-return ledgers, bank purchase and Traitor Deck declarations stay distinct from training. Original phase-end industry, held payer support/physical defenses, physical Suk rescue and subsequent mandatory winner Tech rewards retain their own timing. Homeworlds, native factions with these combined components, Banker/Mentat previews, borrowed Smuggler arithmetic and unresolved interactions remain separate.',
|
||||
'Fresh classic Leader Skills may explicitly add Nexus through local leader-skills --nexus-cards, Basic/Advanced two through six players, retaining selected original Tech at three or more seats and/or Advanced Strongholds. --discoveries additionally composes original Discovery7/8 in this classic profile. Saved hands/offers remain authoritative; the actual end-Spice closing draw follows Maker votes/typed rides, both Advanced piles and settled alliance. Free-return ledgers, bank purchase and Traitor Deck declarations stay distinct from training. Original phase-end industry, held payer support/physical defenses, physical Suk rescue and subsequent mandatory winner Tech rewards retain their own timing. Native Skills/Nexus/Discovery, Homeworlds, Banker/Mentat previews, borrowed Smuggler arithmetic and unresolved interactions remain separate.',
|
||||
'Both original paired Ixian/Tleilaxu or CHOAM/Richese factions plus classics may add Nexus through that fresh local skill entry, with one family deck/all14/all12, Basic/Advanced two through six players. Selected original Tech requires3+; Strongholds requires Advanced. Full unsupported Suboids retain Cyborg support and skill posture; physical Suk rescue precedes equal actual Cyborg Tank substitution. Original winner cleanup and mandatory Tech precede Face Dance, which returns a matching killed trainer skill once without another bounty. Held custody stays with the original winner. Richese Cunning retains its signed two-marker/one-invoice operation and native phase-end industry; ordinary skill proofs and companion exclusions stay separate. CHOAM fuel precedes ordinary Planetologist movement without a second card effect or artificial combat Special.',
|
||||
'Standalone Ecaz OR Moritani with classics can add Nexus through the original fresh skill entry, exact ecaz33/all14/all12, Basic/Advanced2–6, optionally preserving original Tech3+ and/or Advanced Strongholds2+. Ordinary assignment discs and native skill bands remain. Ecaz spends actual Cunning before Battle to acquire the living/unclaimed separate Duke for the turn; normal discipline may apply, never a Duke assignment. Moritani retains Advanced skill-first assassination and normal-Traitor forfeiture, printed post-loss bounty and one private actual Mentat replacement, while Basic keeps legal Terror without gaining that Advanced power. Supply-only Cunning placement stays distinct from ordinary relocation; its enhanced relocation/HMS/Grumman questions remain pending. No public or saved-game conversion.',
|
||||
'In this standalone Ecaz Nexus/Skills profile, mandatory Advanced Occupy keeps the chosen faction’s own leaders, cards, payment and battle skill. Training is not lent between allies. Ecaz-led Suk rescues its own fixed casualties after the ally’s variable losses; Diplomat retreats the selected faction’s undialed own counters, not a remainder computed from the combined dial. Native cancellation restores that selected lead’s ordinary own pool. These owner-labelled compositions are explicit implementation inferences, not a new combined-skill FAQ.',
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
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 { baseDeck } from '../game/cards';
|
||||
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
|
||||
import { validateLeaderSkills } from '../game/leader-skills';
|
||||
import { validateDiscoveryState } from '../game/discoveries';
|
||||
import { validateNexusCards } from '../game/nexus-cards';
|
||||
import { forceRevivalRemaining } from '../game/revival';
|
||||
import { ownedTech } from '../game/tech-tokens';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { botActions } from '../game/bots';
|
||||
import { quoteClassicSkillsStrongholdBattle } from './fixture-classic-skills-stronghold';
|
||||
import {
|
||||
advanceDiscoveryClassicNexusSkills, closeDiscoveryClassicNexusSkills,
|
||||
createDiscoveryClassicNexusSkillsFixture, discoveryClassicNexusSkillsClean,
|
||||
discoveryClassicNexusSkillsPlayer as player, enterDiscoveryClassicNexusSkills,
|
||||
finishDiscoveryClassicNexusSkillsBattle, initializeDiscoveryClassicNexusSkillsSetup,
|
||||
openDiscoveryClassicNexusSkillsBattle, revealDiscoveryClassicNexusSkillsBattle,
|
||||
stepDiscoveryClassicNexusSkills,
|
||||
} from './fixture-discovery-classic-nexus-skills';
|
||||
import {
|
||||
finishClassicDiscoveryNexusMentat, formClassicDiscoveryNexusAlliance, openClassicDiscoveryNexusAlliance,
|
||||
} from './fixture-discovery-classic-nexus';
|
||||
|
||||
function inventory(game: Game): void {
|
||||
validateLeaderSkills(game.leaderSkills!, game.players);
|
||||
validateDiscoveryState(game.discoveries!);
|
||||
validateNexusCards(game.nexusCards!.cards!, game.players);
|
||||
assert.deepEqual([...game.leaderSkills!.deck, ...Object.values(game.leaderSkills!.offers).flatMap(offer => offer.cards),
|
||||
...game.leaderSkills!.assignments.map(assignment => assignment.skill)].sort(), LEADER_SKILL_CARDS.map(card => card.id).sort());
|
||||
assert.deepEqual([...game.deck, ...game.discard, ...game.players.flatMap(seat => seat.hand)].map(card => card.id).sort(),
|
||||
baseDeck().map(card => card.id).sort());
|
||||
for (const seat of game.players) {
|
||||
assert.equal(seat.reserves + seat.tanks + Object.values(seat.forces).reduce((sum, amount) => sum + amount, 0), 20);
|
||||
if (seat.elites) assert.equal(seat.elites.reserves + seat.elites.tanks + Object.values(seat.elites.forces).reduce((sum, amount) => sum + amount, 0),
|
||||
seat.faction === 'emperor' ? 5 : 3);
|
||||
}
|
||||
assert.ok(!(game.discoveryEntry && game.nexusCards!.phase?.stage === 'drawing'));
|
||||
assert.ok(!(game.greatMaker?.stage === 'vote' && game.nexusCards!.phase?.stage === 'drawing'));
|
||||
}
|
||||
function reject(game: Game, actor: string, action: Action): void {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
|
||||
void test('original classic all14/all12/Discovery setup trains before traitors and reaches physical next-turn entry at Basic/Advanced two through six seats', () => {
|
||||
for (const advanced of [false, true]) for (const seats of [2, 3, 4, 5, 6] as const) {
|
||||
const fixture = createDiscoveryClassicNexusSkillsFixture({ advanced, seats, tech: seats >= 3, strongholds: advanced });
|
||||
assert.equal(fixture.setup.status, 'setup'); assert.equal(fixture.setup.leaderSkills!.assignments.length, 0);
|
||||
assert.equal(fixture.setup.spiceDeck.length, 28); assert.equal(fixture.setup.discoveries!.tokens.length, 8);
|
||||
assert.ok(fixture.setup.players.every(seat => !seat.traitors.length && !seat.traitorChoices.length));
|
||||
assert.equal(fixture.trained.leaderSkills!.assignments.length, seats);
|
||||
const trainingSteps = fixture.actions.filter(step => step.action.type === 'leaderSkill');
|
||||
assert.equal(trainingSteps.length, seats);
|
||||
const lastTraining = fixture.actions.findLastIndex(step => step.action.type === 'leaderSkill');
|
||||
const firstTraitor = fixture.actions.findIndex(step => step.action.type === 'traitor');
|
||||
assert.ok(firstTraitor > lastTraining, 'Actual training precedes the original Traitor selection.');
|
||||
assert.equal(fixture.firstMentat.before.phase, 8); assert.equal(fixture.firstMentat.after.turn, 2);
|
||||
assert.equal(fixture.game.phase, 0); assert.equal(fixture.game.storm, fixture.firstMentat.before.storm);
|
||||
const before = player(fixture.game, fixture.owner), entered = enterDiscoveryClassicNexusSkills(fixture), after = player(entered, fixture.owner);
|
||||
assert.equal(after.forces['cistern:0'], 3); assert.equal(after.forces[fixture.source] ?? 0, 0);
|
||||
assert.equal(after.reserves, before.reserves); assert.equal(after.spice, before.spice);
|
||||
assert.equal(after.shipped, before.shipped); assert.equal(after.moved, before.moved);
|
||||
assert.deepEqual(entered.leaderSkills, fixture.game.leaderSkills);
|
||||
if (advanced) {
|
||||
assert.equal(entered.strongholdCards!.owners.arrakeen, fixture.owner);
|
||||
assert.equal(entered.strongholdCards!.claimedTurn, 1);
|
||||
}
|
||||
inventory(fixture.setup); inventory(entered);
|
||||
}
|
||||
});
|
||||
|
||||
void test('authenticated original lobby and undealt human setup retain actual offered training, original hands and actors without demanding a specific skill', () => {
|
||||
const lobby = createGame('HUMANCLASSICNXSKILLDISCOVERY', newPlayer('human-collector', 'Guild', 'guild'), true);
|
||||
joinGame(lobby, newPlayer('human-emperor', 'Emperor', 'emperor'));
|
||||
joinGame(lobby, newPlayer('human-fremen', 'Fremen', 'fremen'));
|
||||
const before = structuredClone(lobby);
|
||||
const setup = initializeDiscoveryClassicNexusSkillsSetup({ initial: lobby, tech: true, strongholds: true });
|
||||
assert.deepEqual(lobby, before);
|
||||
const offered = structuredClone(setup);
|
||||
const fixture = createDiscoveryClassicNexusSkillsFixture({ initial: setup });
|
||||
assert.deepEqual(setup, offered); assert.deepEqual(fixture.setup, offered);
|
||||
assert.deepEqual(fixture.game.players.map(seat => seat.id), ['human-collector', 'human-emperor', 'human-fremen']);
|
||||
for (const step of fixture.actions.filter(step => step.action.type === 'leaderSkill')) {
|
||||
assert.ok(offered.leaderSkills!.offers[step.actor].cards.some(skill => skill === step.action.skill));
|
||||
assert.ok(offered.players.find(seat => seat.id === step.actor)!.leaders.some(leader => leader.id === step.action.leader));
|
||||
}
|
||||
assert.deepEqual(fixture.setup.players.map(seat => seat.hand), offered.players.map(seat => seat.hand));
|
||||
assert.equal(fixture.game.decision?.player, 'human-collector');
|
||||
const closing = closeDiscoveryClassicNexusSkills(fixture);
|
||||
const originalTop = closing.beforeDraw.nexusCards!.cards!.deck[0];
|
||||
assert.equal(closing.game.nexusCards!.cards!.hands[fixture.owner], originalTop,
|
||||
'Human closing draw consumes the actual original top card, not a demanded Nexus identity.');
|
||||
assert.equal(closing.game.nexusCards!.cards!.deck.length, closing.beforeDraw.nexusCards!.cards!.deck.length - 1);
|
||||
inventory(closing.game);
|
||||
assert.throws(() => createDiscoveryClassicNexusSkillsFixture({ initial: fixture.game }));
|
||||
});
|
||||
|
||||
void test('actual Great Maker losses and all votes precede both Advanced piles, settled alliance and the qualified closing draw', () => {
|
||||
const fixture = createDiscoveryClassicNexusSkillsFixture({ advanced: true, tech: true, strongholds: true });
|
||||
const closing = closeDiscoveryClassicNexusSkills(fixture, 'emperor');
|
||||
assert.equal(player(closing.vote, fixture.owner).tanks, 6);
|
||||
assert.equal(player(closing.vote, fixture.owner).forces[fixture.wormSource], undefined);
|
||||
assert.equal(player(closing.vote, fixture.owner).forces['cistern:0'], 3);
|
||||
assert.equal(closing.vote.nexusCards!.cards!.hands[fixture.owner], null);
|
||||
assert.notEqual(closing.vote.nexusCards!.phase?.stage, 'drawing');
|
||||
assert.equal(closing.alliance.greatMaker?.stage, 'complete');
|
||||
assert.equal(closing.beforeDraw.phase, 1); assert.equal(closing.beforeDraw.nexus, false);
|
||||
assert.deepEqual(closing.beforeDraw.nexusCards!.phase!.eligible, [fixture.owner]);
|
||||
assert.equal(closing.beforeDraw.spiceWindow, null); assert.equal(closing.beforeDraw.spiceResolution, null);
|
||||
assert.ok(closing.beforeDraw.spiceDiscard.flat().some(card => 'territory' in card && card.territory === 'broken_land'));
|
||||
assert.ok(closing.beforeDraw.spiceDiscard.flat().filter(card => 'territory' in card && card.territory === 'rock_outcroppings').length >= 1);
|
||||
assert.equal(closing.game.nexusCards!.cards!.hands[fixture.owner], 'emperor');
|
||||
assert.equal(closing.game.phase, 2); assert.equal(closing.game.nexusCards!.phase!.stage, 'complete');
|
||||
assert.deepEqual(closing.game.leaderSkills, fixture.game.leaderSkills);
|
||||
reject(closing.game, fixture.owner, closing.drawStep.action);
|
||||
inventory(closing.vote); inventory(closing.game);
|
||||
});
|
||||
|
||||
void test('own-faction redraw preference and later allied forfeiture use cards supplied by genuine closing draws only', () => {
|
||||
const fixture = createDiscoveryClassicNexusSkillsFixture();
|
||||
const closing = closeDiscoveryClassicNexusSkills(fixture, 'atreides');
|
||||
const before = structuredClone(closing.beforeDraw), cards = before.nexusCards!.cards!;
|
||||
const index = cards.deck.indexOf('emperor'); assert.ok(index > 0);
|
||||
cards.deck.splice(1, 0, cards.deck.splice(index, 1)[0]);
|
||||
const redraw = stepDiscoveryClassicNexusSkills(before, { actor: fixture.owner,
|
||||
action: { ...closing.drawStep.action, ownRedraws: 1 } });
|
||||
assert.equal(redraw.nexusCards!.cards!.hands[fixture.owner], 'emperor');
|
||||
assert.equal(redraw.nexusCards!.cards!.discard.filter(card => card === 'atreides').length, 1);
|
||||
assert.equal(redraw.nexusCards!.cards!.deck.length, before.nexusCards!.cards!.deck.length - 2);
|
||||
inventory(redraw);
|
||||
let game = finishClassicDiscoveryNexusMentat(closing.game).after;
|
||||
game = advanceDiscoveryClassicNexusSkills(game, game => game.phase === 1 && discoveryClassicNexusSkillsClean(game));
|
||||
const wormIndex = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker); assert.ok(wormIndex >= 0);
|
||||
const worm = game.spiceDeck.splice(wormIndex, 1)[0];
|
||||
const landIndex = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(landIndex >= 0);
|
||||
game.spiceDeck.unshift(worm, game.spiceDeck.splice(landIndex, 1)[0]);
|
||||
game = openClassicDiscoveryNexusAlliance(game);
|
||||
const heldWindow = structuredClone(game);
|
||||
const keep = advanceDiscoveryClassicNexusSkills(heldWindow, game => game.nexusCards?.phase?.stage === 'drawing');
|
||||
const kept = stepDiscoveryClassicNexusSkills(keep, { actor: fixture.owner, action: {
|
||||
type: 'nexusCardChoice', turn: keep.turn, card: 'atreides', choice: 'keep', ownRedraws: 0 } });
|
||||
assert.equal(kept.nexusCards!.cards!.hands[fixture.owner], 'atreides');
|
||||
assert.deepEqual(kept.nexusCards!.cards!.deck, keep.nexusCards!.cards!.deck);
|
||||
game = stepDiscoveryClassicNexusSkills(game, { actor: fixture.opponent, action: { type: 'alliance', target: null } });
|
||||
game = formClassicDiscoveryNexusAlliance(game, fixture.owner, fixture.opponent);
|
||||
assert.equal(game.nexusCards!.cards!.hands[fixture.owner], null);
|
||||
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'atreides').length, 1);
|
||||
game = advanceDiscoveryClassicNexusSkills(game, game => game.nexusCards?.phase?.stage === 'drawing');
|
||||
assert.ok(!game.nexusCards!.phase!.eligible.includes(fixture.owner));
|
||||
assert.deepEqual(viewGame(game, fixture.owner).nexusCards!.choices, []);
|
||||
inventory(kept); inventory(game);
|
||||
});
|
||||
|
||||
for (const advanced of [false, true]) for (const kind of ['emperor', 'fremen'] as const) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} trained Discovery ${kind} returns native Great Maker casualties with distinct ordinary ledgers and actual Axlotl settlement`, () => {
|
||||
const fixture = createDiscoveryClassicNexusSkillsFixture({ advanced, tech: true, strongholds: advanced });
|
||||
const closing = closeDiscoveryClassicNexusSkills(fixture, kind);
|
||||
const game = advanceDiscoveryClassicNexusSkills(closing.game, game => game.phase === 4 && discoveryClassicNexusSkillsClean(game));
|
||||
assert.equal(player(game, fixture.owner).tanks, 6, 'Tanks arise from the original worm action, never an injected death.');
|
||||
const view = viewGame(game, fixture.owner), action: Action = { type: kind === 'emperor' ? 'nexusEmperorRevive' : 'nexusFremenRevive',
|
||||
event: kind === 'emperor' ? view.nexusEmperorSecretAlly!.event : view.nexusFremenRevival!.event, elite: 0 };
|
||||
reject(game, fixture.opponent, action);
|
||||
const before = player(game, fixture.owner);
|
||||
let paid = stepDiscoveryClassicNexusSkills(structuredClone(game), { actor: fixture.owner, action });
|
||||
const after = player(paid, fixture.owner);
|
||||
assert.equal(after.reserves, before.reserves + 3); assert.equal(after.tanks, 3); assert.equal(after.spice, before.spice);
|
||||
assert.equal(after.revived, kind === 'fremen' ? 3 : 0);
|
||||
assert.equal(after.freeForcesRevived ?? 0, kind === 'fremen' ? 3 : 0);
|
||||
assert.equal(forceRevivalRemaining(paid, after), kind === 'fremen' ? 0 : 3);
|
||||
assert.equal(paid.nexusCards!.cards!.hands[fixture.owner], null);
|
||||
assert.equal(paid.nexusCards!.cards!.discard.filter(card => card === kind).length, 1);
|
||||
assert.deepEqual(paid.leaderSkills, game.leaderSkills);
|
||||
const history = kind === 'emperor' ? paid.nexusEmperorSecretHistory! : paid.nexusFremenRevivalHistory!;
|
||||
assert.equal(history.length, 1); assert.equal(history[0].owner, fixture.owner);
|
||||
assert.equal(history[0].before.tanks - history[0].after.tanks, 3);
|
||||
reject(paid, fixture.owner, action);
|
||||
if (kind === 'emperor') {
|
||||
paid = stepDiscoveryClassicNexusSkills(paid, { actor: fixture.owner, action: { type: 'revive', amount: 3 } });
|
||||
paid = advanceDiscoveryClassicNexusSkills(paid, discoveryClassicNexusSkillsClean);
|
||||
assert.equal(player(paid, fixture.owner).tanks, 0); assert.equal(player(paid, fixture.owner).revived, 3);
|
||||
assert.equal(player(paid, fixture.owner).freeForcesRevived, 2); assert.equal(player(paid, fixture.owner).spice, before.spice - 2);
|
||||
} else reject(paid, fixture.owner, { type: 'revive', amount: 1 });
|
||||
const owner = paid.techTokens!.axlotl.owner!;
|
||||
const credit = ownedTech(paid.techTokens, owner).length;
|
||||
assert.equal(paid.techTokens!.axlotl.triggeredTurn, 2); assert.equal(paid.techTokens!.axlotl.spice, credit);
|
||||
const wallet = player(paid, owner).spice;
|
||||
const movement = advanceDiscoveryClassicNexusSkills(paid, game => game.phase === 5 && discoveryClassicNexusSkillsClean(game));
|
||||
assert.equal(player(movement, owner).spice, wallet + credit); assert.equal(movement.techTokens!.axlotl.spice, 0);
|
||||
assert.deepEqual(movement.leaderSkills, fixture.game.leaderSkills); inventory(movement);
|
||||
let ordinaryFirst = stepDiscoveryClassicNexusSkills(structuredClone(game), { actor: fixture.owner,
|
||||
action: { type: 'revive', amount: kind === 'emperor' ? 3 : 1 } });
|
||||
ordinaryFirst = advanceDiscoveryClassicNexusSkills(ordinaryFirst, discoveryClassicNexusSkillsClean);
|
||||
if (kind === 'emperor') {
|
||||
const extra = stepDiscoveryClassicNexusSkills(ordinaryFirst, { actor: fixture.owner, action });
|
||||
assert.equal(player(extra, fixture.owner).tanks, 0);
|
||||
assert.equal(player(extra, fixture.owner).revived, 3);
|
||||
assert.equal(player(extra, fixture.owner).freeForcesRevived, 2);
|
||||
assert.equal(player(extra, fixture.owner).spice, player(ordinaryFirst, fixture.owner).spice);
|
||||
assert.deepEqual(extra.techTokens, ordinaryFirst.techTokens, 'Extra three cannot retrigger the already earned original Axlotl activity.');
|
||||
inventory(extra);
|
||||
} else {
|
||||
reject(ordinaryFirst, fixture.owner, action);
|
||||
assert.equal(player(ordinaryFirst, fixture.owner).tanks, 5);
|
||||
assert.equal(player(ordinaryFirst, fixture.owner).revived, 1);
|
||||
assert.equal(ordinaryFirst.nexusCards!.cards!.hands[fixture.owner], 'fremen');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void test('borrowed Guild nested shipment retains physical training and held Arrakeen, then actual skilled Warmaster cleanup and mandatory Tech transfer occur in order', () => {
|
||||
const fixture = createDiscoveryClassicNexusSkillsFixture({ advanced: true, tech: true, strongholds: true, skill: 'warmaster' });
|
||||
const closing = closeDiscoveryClassicNexusSkills(fixture, 'guild');
|
||||
const battle = openDiscoveryClassicNexusSkillsBattle(fixture, closing.game);
|
||||
const before = player(battle.shipmentWindow, fixture.owner), arrived = player(battle.arrived, fixture.owner);
|
||||
assert.equal(arrived.forces['cistern:0'], 6); assert.equal(arrived.reserves, before.reserves - 3);
|
||||
assert.equal(arrived.spice, before.spice - 2); assert.equal(arrived.shipped, true); assert.equal(arrived.moved, before.moved);
|
||||
assert.equal(battle.arrived.nexusCards!.cards!.hands[fixture.owner], null);
|
||||
assert.equal(battle.arrived.nexusGuildSecretHistory!.length, 1);
|
||||
assert.equal(battle.arrived.nexusGuildSecretHistory![0].receipt.owner, fixture.owner);
|
||||
assert.equal(battle.arrived.strongholdCards!.owners.arrakeen, fixture.owner);
|
||||
assert.deepEqual(battle.arrived.leaderSkills, battle.shipmentWindow.leaderSkills);
|
||||
reject(battle.shipmentWindow, fixture.owner, { ...battle.shipmentStep.action, smuggler: true });
|
||||
const industryOwner = battle.arrived.techTokens!.heighliners.owner!;
|
||||
const industryCredit = ownedTech(battle.arrived.techTokens, industryOwner).length;
|
||||
assert.equal(battle.arrived.techTokens!.heighliners.triggeredTurn, 2);
|
||||
assert.equal(battle.arrived.techTokens!.heighliners.spice, industryCredit);
|
||||
assert.equal(battle.game.techTokens!.heighliners.spice, 0);
|
||||
assert.equal(player(battle.game, industryOwner).spice, player(battle.arrived, industryOwner).spice + industryCredit,
|
||||
'Actual movement end settles shipping industry exactly once before the private nested plans.');
|
||||
const revealed = revealDiscoveryClassicNexusSkillsBattle(battle), quote = quoteClassicSkillsStrongholdBattle(revealed);
|
||||
assert.equal(quote.winner, fixture.owner);
|
||||
assert.equal(viewGame(revealed, fixture.owner).battle!.strongholdEffects[fixture.owner], null);
|
||||
const payment = quote.payments.find(receipt => receipt.player === fixture.owner)!;
|
||||
assert.equal(payment.bankSupport, 0); assert.equal(payment.ownPayment, 1);
|
||||
const side = revealed.battle!.attacker === fixture.owner ? 'attacker' : 'defender';
|
||||
assert.deepEqual(quote.leaderSkillBonuses[side].applied, [{ skill: 'warmaster', amount: 3, mode: 'skilled' }]);
|
||||
const loser = player(revealed, fixture.opponent), reward = ownedTech(revealed.techTokens, fixture.opponent);
|
||||
assert.ok(reward.length > 0);
|
||||
const cleanup = finishDiscoveryClassicNexusSkillsBattle(revealed, true);
|
||||
assert.equal(player(cleanup, fixture.owner).forces['cistern:0'], 5);
|
||||
assert.equal(player(cleanup, fixture.opponent).forces['cistern:0'] ?? 0, 0);
|
||||
assert.equal(player(cleanup, fixture.opponent).tanks, loser.tanks + 2);
|
||||
assert.deepEqual(cleanup.techTokens, revealed.techTokens, 'Battle cleanup still owns its priority before the winner reward.');
|
||||
const done = finishDiscoveryClassicNexusSkillsBattle(cleanup);
|
||||
assert.equal(done.lastBattleContext!.winner, fixture.owner);
|
||||
assert.ok(player(done, fixture.owner).hand.some(card => card.id === battle.weapon));
|
||||
assert.ok(!done.discard.some(card => card.id === battle.weapon));
|
||||
assert.equal(viewGame(done, fixture.owner).leaderSkills!.assignments.find(assignment => assignment.leader === fixture.leader)!.faceUp, true);
|
||||
for (const token of reward) assert.equal(done.techTokens![token].owner, fixture.owner);
|
||||
assert.deepEqual(ownedTech(done.techTokens, fixture.opponent), []);
|
||||
assert.deepEqual(done.nexusGuildSecretHistory, battle.arrived.nexusGuildSecretHistory);
|
||||
assert.equal(done.strongholdCards!.owners.arrakeen, fixture.owner);
|
||||
inventory(done);
|
||||
});
|
||||
|
||||
void test('all four minimal policies produce actual offered training, physical entry, qualified Nexus draw and source-clear return outcomes', () => {
|
||||
const fixture = createDiscoveryClassicNexusSkillsFixture({ advanced: true, tech: true });
|
||||
const closing = closeDiscoveryClassicNexusSkills(fixture, 'emperor');
|
||||
const revival = advanceDiscoveryClassicNexusSkills(closing.game, game => game.phase === 4 && discoveryClassicNexusSkillsClean(game));
|
||||
const training = advanceDiscoveryClassicNexusSkills(structuredClone(fixture.setup), game => game.setupStage === 'leaderSkills');
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const cases = [
|
||||
{ game: training, actor: Object.keys(training.leaderSkills!.offers)[0], type: 'leaderSkill' },
|
||||
{ game: fixture.game, actor: fixture.owner, type: 'decision' },
|
||||
{ game: closing.beforeDraw, actor: fixture.owner, type: 'nexusCardChoice' },
|
||||
{ game: revival, actor: fixture.owner, type: 'nexusEmperorRevive' },
|
||||
];
|
||||
for (const current of cases) {
|
||||
const view = viewGame(current.game, current.actor); view.players.find(seat => seat.id === current.actor)!.bot = difficulty;
|
||||
const untouched = structuredClone(view), action = botActions(view).find(action => action.type === current.type);
|
||||
assert.ok(action, `${difficulty} needs the actual owned ${current.type}.`);
|
||||
const after = stepDiscoveryClassicNexusSkills(structuredClone(current.game), { actor: current.actor, action });
|
||||
assert.deepEqual(view, untouched);
|
||||
if (current.type === 'leaderSkill') {
|
||||
assert.equal(after.leaderSkills!.assignments.length, 1);
|
||||
const assignment = after.leaderSkills!.assignments[0]; assert.equal(assignment.owner, current.actor);
|
||||
assert.ok(current.game.leaderSkills!.offers[current.actor].cards.includes(assignment.skill));
|
||||
assert.equal(after.leaderSkills!.offers[current.actor], undefined);
|
||||
assert.deepEqual(after.players.map(seat => seat.hand), current.game.players.map(seat => seat.hand));
|
||||
} else if (current.type === 'decision') {
|
||||
assert.equal(after.discoveryEntry, undefined);
|
||||
const entered = player(after, current.actor).forces['cistern:0'] ?? 0;
|
||||
assert.equal((player(after, current.actor).forces[fixture.source] ?? 0) + entered, 3);
|
||||
assert.equal(player(after, current.actor).spice, player(current.game, current.actor).spice);
|
||||
assert.deepEqual(after.leaderSkills, current.game.leaderSkills);
|
||||
} else if (current.type === 'nexusCardChoice') {
|
||||
assert.equal(after.nexusCards!.phase!.stage, 'complete');
|
||||
assert.ok(after.nexusCards!.cards!.hands[current.actor]);
|
||||
assert.equal(after.phase, 2);
|
||||
} else {
|
||||
assert.equal(player(after, current.actor).tanks, 3);
|
||||
assert.equal(player(after, current.actor).reserves, player(current.game, current.actor).reserves + 3);
|
||||
assert.equal(player(after, current.actor).revived, 0);
|
||||
assert.equal(after.nexusCards!.cards!.hands[current.actor], null);
|
||||
assert.equal(after.techTokens!.axlotl.triggeredTurn, 2);
|
||||
assert.deepEqual(after.leaderSkills, current.game.leaderSkills);
|
||||
}
|
||||
inventory(after);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -57,6 +57,7 @@ void test('Discovery Skills cannot silently drop unsupported native families or
|
||||
assert.throws(() => initializeLeaderSkillsGameForAudit(unsupported), RuleError);
|
||||
const overlays = Array.from({ length: 5 }, () => lobby(['guild', 'emperor', 'harkonnen'], []));
|
||||
overlays[0].homeworlds = { custody: null };
|
||||
overlays[1] = lobby(['ixians', 'tleilaxu', 'guild'], ['ix']);
|
||||
overlays[1].nexusCards = { cards: null, phase: null };
|
||||
overlays[2].advanced = false; overlays[2].strongholdCards = createStrongholdCards();
|
||||
overlays[3].mentatQuestionPreview = true;
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
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 { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { MOBILE_LOCATION, validGameLocation } from '../game/board';
|
||||
import { treacheryDeck } from '../game/cards';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { validateRicheseNoField } from '../game/richese-no-field';
|
||||
import { DISCOVERY_SPICE_CARDS, validateDiscoveryState } from '../game/discoveries';
|
||||
import { validateNexusCards } from '../game/nexus-cards';
|
||||
import { nexusFaceDancersAction } from '../game/nexus-tleilaxu-options';
|
||||
import { ownedTech } from '../game/tech-tokens';
|
||||
import { traitorDeck } from '../game/traitors';
|
||||
import { quoteStrongholdFactionsBattle } from './fixture-stronghold-factions';
|
||||
import {
|
||||
advancePairedDiscoveryNexus, applyPairedDiscoveryNexusStep, closePairedDiscoveryNexus,
|
||||
createPairedDiscoveryNexusFixture, holdPairedDiscoveryNexusCard,
|
||||
initializePairedDiscoveryNexusForFixture, nextPairedDiscoveryNexusStep,
|
||||
pairedDiscoveryNexusClean, pairedDiscoveryNexusPlayer as player, placePairedDiscoveryNexus,
|
||||
pairedDiscoveryRicheseRequest, preparePairedDiscoveryNexusBattle,
|
||||
reservePairedDiscoveryNexusBoard, revealPairedDiscoveryNexusBattle, settlePairedDiscoveryNexusShipment,
|
||||
type PairedDiscoveryNexusFixture,
|
||||
} from './fixture-discovery-paired-nexus';
|
||||
|
||||
function reject(game: Game, actor: string, action: Action): void {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before, 'Rejected native choices leave original cards, wallets, subtype counters and receipts unchanged.');
|
||||
}
|
||||
function custody(game: Game): void {
|
||||
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
|
||||
...(game.ixSetupCards ?? []), ...(game.ixAuction?.cards ?? []),
|
||||
...(game.auction?.cards.slice(game.auction.index) ?? []),
|
||||
...(game.richeseCache ?? []), ...(game.richeseRemoved ?? [])];
|
||||
const expected = [...treacheryDeck(game.expansions), ...(game.expansions[0] === 'choam' ? richeseCards() : [])];
|
||||
assert.deepEqual(cards.map(c => c.id).sort(), expected.map(c => c.id).sort());
|
||||
assert.equal(new Set(cards.map(c => c.id)).size, cards.length);
|
||||
const identities = [...(game.traitorReserve ?? []), ...game.players.flatMap(p => [
|
||||
...p.traitors, ...p.traitorChoices, ...(p.faceDancers ?? []).map(c => c.leader),
|
||||
])];
|
||||
assert.deepEqual(identities.sort(), traitorDeck(game.players, game.expansions[0] === 'ix').sort());
|
||||
assert.equal(new Set(identities).size, identities.length);
|
||||
for (const p of game.players) {
|
||||
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum, n) => sum + n, 0), 20, p.faction);
|
||||
if (p.elites) {
|
||||
assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0),
|
||||
p.faction === 'ixians' ? 7 : p.faction === 'fremen' ? 3 : 5);
|
||||
assert.ok(p.elites.reserves <= p.reserves && p.elites.tanks <= p.tanks);
|
||||
for (const [key, count] of Object.entries(p.elites.forces)) assert.ok(count >= 0 && count <= (p.forces[key] ?? 0));
|
||||
}
|
||||
if (p.noField) validateRicheseNoField(p.noField);
|
||||
}
|
||||
validateDiscoveryState(game.discoveries!);
|
||||
validateNexusCards(game.nexusCards!.cards!, game.players);
|
||||
assert.ok(!game.leaderSkills && !game.homeworlds);
|
||||
}
|
||||
function richeseWindow(f: PairedDiscoveryNexusFixture, state: Game): Game {
|
||||
return advancePairedDiscoveryNexus(state, g => g.phase === 5 && g.active === f.owner && pairedDiscoveryNexusClean(g));
|
||||
}
|
||||
function richeseReveal(game: Game, owner: string): Game {
|
||||
const p = player(game, owner); assert.ok(p.noField!.deployed);
|
||||
return applyAction(game, owner, { type: 'revealNoField', token: p.noField!.deployed.tokenId, event: p.noFieldEvent });
|
||||
}
|
||||
|
||||
for (const family of ['ix', 'choam'] as const) for (const mode of [
|
||||
{ advanced: false, tech: false, strongholds: false },
|
||||
{ advanced: false, tech: true, strongholds: false },
|
||||
{ advanced: true, tech: false, strongholds: false },
|
||||
{ advanced: true, tech: true, strongholds: false },
|
||||
{ advanced: true, tech: false, strongholds: true },
|
||||
{ advanced: true, tech: true, strongholds: true },
|
||||
]) void test(`paired ${family} ${JSON.stringify(mode)}: actual printed reveal, END Mentat, typed entry/Maker ride and alliance-qualified closing native deals`, () => {
|
||||
const f = createPairedDiscoveryNexusFixture({ family, ...mode });
|
||||
assert.deepEqual(f.setup.spiceDeck.flatMap(c => 'territory' in c && c.discovery ? [c.discovery] : []).sort(),
|
||||
DISCOVERY_SPICE_CARDS.map(c => c.discovery).sort());
|
||||
assert.equal(f.setup.spiceDeck.filter(c => 'worm' in c && c.greatMaker).length, 1);
|
||||
assert.equal(f.setup.spiceDeck.filter(c => 'sandtrout' in c && c.sandtrout).length, family === 'ix' ? 1 : 0);
|
||||
assert.equal(f.setup.discoveries!.tokens.length, 8);
|
||||
assert.equal(f.afterSetup.players.reduce((sum, p) => sum + p.hand.length, 0) + f.afterSetup.deck.length,
|
||||
family === 'ix' ? 47 : 35);
|
||||
assert.equal(f.game.decision?.kind, 'discoveryEntry'); assert.equal(f.game.phase, 0); assert.equal(f.game.turn, 2);
|
||||
assert.ok(f.actions.some(s => s.action.type === 'ship') && f.actions.some(s => s.action.type === 'discovery'));
|
||||
assert.ok(Object.values(f.game.nexusCards!.cards!.hands).every(c => c === null), 'END Mentat/free entry do not manufacture a Nexus hand.');
|
||||
if (mode.strongholds) assert.equal(f.firstMentat.after.strongholdCards!.owners[f.claim], f.owner);
|
||||
const c = closePairedDiscoveryNexus(f), old = player(c.entry.before, f.collector), entered = player(c.entry.after, f.collector);
|
||||
const total = family === 'ix' ? 2 : 1, elite = family === 'ix' ? 1 : 0;
|
||||
assert.equal(entered.forces['shrine:0'], total);
|
||||
assert.equal(entered.elites?.forces['shrine:0'] ?? 0, elite);
|
||||
assert.equal(entered.forces[f.source], 3 - total);
|
||||
assert.equal(entered.reserves, old.reserves); assert.equal(entered.spice, old.spice);
|
||||
assert.equal(entered.moved, old.moved); assert.equal(entered.shipped, old.shipped);
|
||||
assert.equal(player(c.vote, f.native).forces['hagga_basin:12'] ?? 0, 0);
|
||||
assert.equal(player(c.vote, f.native).tanks, player(f.game, f.native).tanks + 2, 'Actual Great Maker ordinary worm losses precede its vote.');
|
||||
assert.ok(Object.values(c.vote.nexusCards!.cards!.hands).every(c => c === null));
|
||||
const rider = player(c.ride.before, f.fremen), arrived = player(c.ride.after, f.fremen);
|
||||
assert.equal(arrived.forces['polar_sink:0'], 2);
|
||||
assert.equal(arrived.elites?.forces['polar_sink:0'] ?? 0, mode.advanced ? 1 : 0);
|
||||
assert.equal(arrived.reserves, rider.reserves - 2);
|
||||
assert.equal(arrived.elites?.reserves ?? 0, (rider.elites?.reserves ?? 0) - (mode.advanced ? 1 : 0));
|
||||
assert.equal(arrived.spice, rider.spice); assert.equal(arrived.shipped, rider.shipped); assert.equal(arrived.moved, rider.moved);
|
||||
assert.notEqual(c.ride.after.nexusCards!.phase?.stage, 'drawing', 'Maker reserve ride is not the closing phase draw.');
|
||||
assert.equal(c.drawing.greatMaker!.stage, 'complete');
|
||||
assert.ok(!c.drawing.spiceWindow && !c.drawing.spiceResolution);
|
||||
assert.equal(player(c.drawing, f.guild).ally, f.fremen); assert.equal(player(c.drawing, f.fremen).ally, f.guild);
|
||||
assert.deepEqual([...c.drawing.nexusCards!.phase!.eligible].sort(), [f.owner, f.native].sort());
|
||||
if (mode.advanced) assert.ok(c.drawing.spiceDiscard[1].some(card => 'territory' in card && card.territory === 'rock_outcroppings'),
|
||||
'The actual second Advanced pile closes before any physical Nexus draw.');
|
||||
const faces = family === 'ix' ? ['ixians', 'tleilaxu'] : ['richese', 'choam'];
|
||||
assert.equal(c.afterDraw.nexusCards!.cards!.hands[f.owner], faces[0]);
|
||||
assert.equal(c.afterDraw.nexusCards!.cards!.hands[f.native], faces[1]);
|
||||
assert.equal(c.afterDraw.nexusCards!.cards!.hands[f.guild], null); assert.equal(c.afterDraw.nexusCards!.cards!.hands[f.fremen], null);
|
||||
if (mode.strongholds) assert.deepEqual(c.afterDraw.strongholdCards, f.firstMentat.after.strongholdCards);
|
||||
for (const game of [f.afterSetup, f.game, c.entry.after, c.vote, c.ride.after, c.drawing, c.afterDraw]) custody(game);
|
||||
});
|
||||
|
||||
void test('paired native Great Maker vote and both Spice piles without a settled alliance deal no Nexus cards', () => {
|
||||
const f = createPairedDiscoveryNexusFixture({ family: 'ix' }), c = closePairedDiscoveryNexus(f);
|
||||
const game = advancePairedDiscoveryNexus(c.ride.after, g => g.phase === 2 && pairedDiscoveryNexusClean(g));
|
||||
assert.ok(game.players.every(p => p.ally === null));
|
||||
assert.ok(Object.values(game.nexusCards!.cards!.hands).every(card => card === null));
|
||||
assert.equal(game.nexusCards!.cards!.deck.length, c.ride.after.nexusCards!.cards!.deck.length);
|
||||
assert.notEqual(game.nexusCards!.phase?.stage, 'drawing');
|
||||
custody(game);
|
||||
});
|
||||
|
||||
void test('paired E2 immediate and concealed groups are capped by the original reserve pool without a second tariff', () => {
|
||||
const f = createPairedDiscoveryNexusFixture({ family: 'choam', advanced: false, tech: true, strongholds: false });
|
||||
const c = closePairedDiscoveryNexus(f), window = richeseWindow(f, c.afterDraw);
|
||||
const count = player(window, f.owner).reserves - 2;
|
||||
placePairedDiscoveryNexus(window, f.owner, 'polar_sink:0', count);
|
||||
f.staging.push(`Conserved ${count} original Richese reserves → Polar Sink; exactly two physical reserves remain before declaration.`);
|
||||
const request = pairedDiscoveryRicheseRequest(f, window), before = player(window, f.owner);
|
||||
const shipped = settlePairedDiscoveryNexusShipment(applyAction(window, f.owner, request));
|
||||
assert.equal(player(shipped, f.owner).forces['shrine:0'], 2); assert.equal(player(shipped, f.owner).reserves, 0);
|
||||
const materialized = richeseReveal(shipped, f.owner);
|
||||
assert.equal(player(materialized, f.owner).forces['shrine:0'], 2);
|
||||
assert.equal(player(materialized, f.owner).reserves, 0);
|
||||
assert.equal(player(materialized, f.owner).spice, before.spice - 1);
|
||||
assert.equal(player(materialized, f.guild).spice, player(window, f.guild).spice + 1);
|
||||
assert.equal(player(materialized, f.owner).noField!.lastShipped, request.noField);
|
||||
assert.equal(materialized.nexusCards!.cards!.discard.filter(card => card === 'richese').length, 1);
|
||||
custody(materialized);
|
||||
});
|
||||
|
||||
for (const advanced of [false, true]) void test(`paired E1 ${advanced ? 'Advanced' : 'Basic'}: original Cunning typed casualties, original Tech before nested Face Dance and draw-before-retire Tleilaxu Cunning`, () => {
|
||||
const f = createPairedDiscoveryNexusFixture({ family: 'ix', advanced, tech: true, strongholds: advanced });
|
||||
const closing = closePairedDiscoveryNexus(f), b = preparePairedDiscoveryNexusBattle(f, closing.afterDraw);
|
||||
const offer = viewGame(b.game, f.owner).nexusSuboids!.offer!; assert.ok(offer && !offer.blocked);
|
||||
const action: Action = { type: 'nexusSuboids', event: offer.event };
|
||||
reject(b.game, f.native, action);
|
||||
const used = applyAction(b.game, f.owner, action);
|
||||
assert.equal(used.nexusCards!.cards!.hands[f.owner], null);
|
||||
assert.equal(used.nexusCards!.cards!.discard.filter(c => c === 'ixians').length, 1);
|
||||
assert.deepEqual(player(used, f.owner).forces, player(b.game, f.owner).forces);
|
||||
assert.deepEqual(player(used, f.owner).elites, player(b.game, f.owner).elites);
|
||||
reject(used, f.owner, action);
|
||||
const revealed = revealPairedDiscoveryNexusBattle(b, used), quote = quoteStrongholdFactionsBattle(revealed);
|
||||
assert.equal(quote.winner, f.owner);
|
||||
assert.equal(quote.casualties!.forces.normalFixedHalf, false);
|
||||
assert.equal(quote.casualties!.forces.normalFreeSupport, true);
|
||||
assert.equal(viewGame(revealed, f.owner).battle!.strongholdEffects[f.owner], null, 'Retained Arrakeen does not subsidize a different nested stronghold-price site.');
|
||||
if (advanced) assert.equal(quote.payments.find(p => p.player === f.owner)!.bankSupport, 0);
|
||||
let losses = advancePairedDiscoveryNexus(revealed, g => g.decision?.kind === 'battleLosses' || g.decision?.kind === 'faceDance');
|
||||
if (losses.decision?.kind === 'battleLosses') {
|
||||
const choice = losses.decision.options.findIndex(o => o.normal === 2 && o.elite === 0); assert.ok(choice >= 0);
|
||||
losses = applyAction(losses, f.owner, { type: 'decision', choice });
|
||||
}
|
||||
const pending = advancePairedDiscoveryNexus(losses, g => g.decision?.kind === 'faceDance');
|
||||
assert.equal(pending.lastBattleContext!.winner, f.owner);
|
||||
assert.equal(player(pending, f.owner).forces['shrine:0'], 4);
|
||||
assert.equal(player(pending, f.owner).elites!.forces['shrine:0'], 3);
|
||||
assert.equal(player(pending, f.owner).tanks, player(revealed, f.owner).tanks + 2);
|
||||
assert.equal(player(pending, f.owner).elites!.tanks, player(revealed, f.owner).elites!.tanks);
|
||||
assert.equal(player(pending, f.native).tanks, player(revealed, f.native).tanks + 6);
|
||||
assert.ok(player(pending, f.owner).hand.some(c => c.id === b.weapon) && player(pending, f.owner).hand.some(c => c.id === b.defense));
|
||||
const defeatedTech = ownedTech(revealed.techTokens, f.native); assert.equal(defeatedTech.length, 1);
|
||||
assert.equal(pending.techTokens![defeatedTech[0]].owner, f.owner, 'Original winner already owns the actual loser Tech before replacement.');
|
||||
const winner = player(pending, f.owner), dancer = player(pending, f.native), stock = structuredClone(dancer.faceDancers!);
|
||||
const dance: Action = { type: 'decision', reveal: true, sector: 0, sources: { reserves: 2, [f.source]: 2 } };
|
||||
reject(pending, f.native, { ...dance, sector: 1 }); reject(pending, f.native, { ...dance, sources: { [MOBILE_LOCATION]: 4 } });
|
||||
reject(pending, f.native, { ...dance, sources: { reserves: 5 } });
|
||||
const after = applyAction(pending, f.native, dance), returned = player(after, f.owner), replaced = player(after, f.native);
|
||||
assert.equal(returned.forces['shrine:0'] ?? 0, 0); assert.equal(returned.elites!.forces['shrine:0'] ?? 0, 0);
|
||||
assert.equal(returned.reserves, winner.reserves + 4); assert.equal(returned.elites!.reserves, winner.elites!.reserves + 3);
|
||||
assert.equal(returned.tanks, winner.tanks); assert.equal(returned.spice, winner.spice); assert.deepEqual(returned.hand, winner.hand);
|
||||
assert.equal(returned.leaders.find(l => l.id === b.leader)!.dead, true);
|
||||
assert.equal(replaced.forces['shrine:0'], 4); assert.equal(replaced.forces[f.source], 1); assert.equal(replaced.reserves, dancer.reserves - 2);
|
||||
assert.deepEqual(after.techTokens, pending.techTokens); assert.deepEqual(after.strongholdCards, pending.strongholdCards);
|
||||
const revealedIdentity = replaced.faceDancers!.find(c => c.revealed)!; assert.ok(revealedIdentity);
|
||||
const untouched = stock.filter(c => c.leader !== revealedIdentity.leader), reserve = [...after.traitorReserve!];
|
||||
const view = viewGame(after, f.native), replacement = nexusFaceDancersAction(view, view.nexusTleilaxu!.cunning!.event); assert.ok(replacement);
|
||||
reject(after, f.native, { ...replacement, leaders: [revealedIdentity.leader] });
|
||||
const renewed = applyAction(after, f.native, replacement), nextStock = player(renewed, f.native).faceDancers!;
|
||||
assert.deepEqual(nextStock.filter(c => untouched.some(old => old.leader === c.leader)), untouched);
|
||||
assert.deepEqual(nextStock.filter(c => !untouched.some(old => old.leader === c.leader)), [{ leader: reserve[0], revealed: false }]);
|
||||
assert.deepEqual([...renewed.traitorReserve!].sort(), [...reserve.slice(1), revealedIdentity.leader].sort());
|
||||
assert.equal(player(renewed, f.native).faceDancerReplacedTurn, replaced.faceDancerReplacedTurn);
|
||||
assert.equal(renewed.nexusCards!.cards!.discard.filter(c => c === 'tleilaxu').length, 1);
|
||||
reject(renewed, f.native, replacement); custody(renewed);
|
||||
reservePairedDiscoveryNexusBoard(renewed, ['shrine:0', f.source, MOBILE_LOCATION]);
|
||||
f.staging.push('After genuine Face Dance/replacement: conserve only the unrelated second-conflict groups back to their original reserves; original native Mentat closes the turn and expires Cunning.');
|
||||
const nextTurn = advancePairedDiscoveryNexus(renewed, g => g.turn > renewed.turn);
|
||||
assert.equal(viewGame(nextTurn, f.owner).nexusSuboids!.active, false);
|
||||
assert.equal(nextTurn.nexusCards!.cards!.discard.filter(card => card === 'ixians').length, 1);
|
||||
if (advanced) assert.equal(nextTurn.strongholdCards!.owners.arrakeen, null, 'Only the actual next END Mentat updates the held card to the conserved current board.');
|
||||
custody(nextTurn);
|
||||
});
|
||||
|
||||
for (const advanced of [false, true]) void test(`paired E2 ${advanced ? 'Advanced' : 'Basic'}: signed nested pair charges one physical tariff, native reveal and CHOAM support invoice`, () => {
|
||||
const f = createPairedDiscoveryNexusFixture({ family: 'choam', advanced, tech: true, strongholds: advanced });
|
||||
const closing = closePairedDiscoveryNexus(f), window = richeseWindow(f, closing.afterDraw), request = pairedDiscoveryRicheseRequest(f, window);
|
||||
const before = player(window, f.owner), guild = player(window, f.guild);
|
||||
assert.equal(validGameLocation(window, 'shrine', 0), true); assert.equal(validGameLocation(window, 'cistern', 0), false);
|
||||
reject(window, f.owner, { ...request, territory: 'cistern' }); reject(window, f.owner, { ...request, territory: 'invented-nested' });
|
||||
reject(window, f.owner, { ...request, sector: 1 }); reject(window, f.owner, { ...request, revealedToken: request.noField });
|
||||
holdPairedDiscoveryNexusCard(window, f.guild, c => c.effect === 'karama', f.staging);
|
||||
const declared = applyAction(window, f.owner, request);
|
||||
assert.equal(declared.response?.kind, 'richeseNoField'); assert.equal(declared.pendingShipment!.cost, 1);
|
||||
assert.equal(declared.pendingShipment!.amount, 1); assert.equal(declared.nexusCards!.cards!.hands[f.owner], null);
|
||||
assert.equal(player(declared, f.owner).spice, before.spice); assert.equal(player(declared, f.owner).reserves, before.reserves);
|
||||
for (const target of ['cistern', 'invented-nested']) {
|
||||
const corrupt = structuredClone(declared); corrupt.pendingShipment!.territory = target;
|
||||
const next = nextPairedDiscoveryNexusStep(corrupt); assert.ok(next);
|
||||
reject(corrupt, next.actor, next.action);
|
||||
}
|
||||
const shipped = settlePairedDiscoveryNexusShipment(declared), owner = player(shipped, f.owner);
|
||||
assert.equal(owner.spice, before.spice - 1); assert.equal(player(shipped, f.guild).spice, guild.spice + 1);
|
||||
assert.equal(owner.reserves, before.reserves - 5); assert.equal(owner.forces['shrine:0'], 5);
|
||||
assert.equal(owner.noField!.deployed!.tokenId, request.noField); assert.equal(owner.noField!.lastShipped, request.noField);
|
||||
assert.deepEqual(owner.noField!.tokens, before.noField!.tokens);
|
||||
assert.equal(shipped.nexusRicheseCunningLast!.stage, 'shipped'); assert.equal(shipped.nexusCards!.cards!.discard.filter(c => c === 'richese').length, 1);
|
||||
const publicView = viewGame(shipped, f.guild); assert.equal(publicView.richeseNoField!.private, null);
|
||||
for (const token of owner.noField!.tokens) assert.equal(JSON.stringify(publicView).includes(token.id), false);
|
||||
reject(shipped, f.owner, request);
|
||||
for (const target of ['cistern', 'invented-nested']) {
|
||||
const corrupt = structuredClone(shipped); corrupt.players.find(p => p.id === f.owner)!.noField!.deployed!.location = { territory: target, sector: 0 };
|
||||
reject(corrupt, f.owner, { type: 'revealNoField', token: String(request.noField), event: owner.noFieldEvent });
|
||||
}
|
||||
const revealedMarker = richeseReveal(shipped, f.owner), materialized = player(revealedMarker, f.owner);
|
||||
assert.equal(materialized.forces['shrine:0'], 8); assert.equal(materialized.reserves, before.reserves - 8);
|
||||
assert.equal(materialized.spice, before.spice - 1); assert.equal(materialized.noField!.deployed, null);
|
||||
assert.equal(materialized.noField!.lastShipped, request.noField); custody(revealedMarker);
|
||||
const b = preparePairedDiscoveryNexusBattle(f, revealedMarker), revealed = revealPairedDiscoveryNexusBattle(b);
|
||||
const quote = quoteStrongholdFactionsBattle(revealed); assert.equal(quote.winner, f.owner);
|
||||
assert.equal(viewGame(revealed, f.owner).battle!.strongholdEffects[f.owner], null, 'Held Carthag is not inherited inside Shrine.');
|
||||
if (advanced) {
|
||||
const invoice = quote.payments.find(p => p.player === f.owner)!;
|
||||
assert.equal(invoice.ownPayment, 3); assert.equal(invoice.bankSupport, 0);
|
||||
assert.deepEqual(quote.choamIncome, { owner: f.native, amount: 1 });
|
||||
} else { assert.deepEqual(quote.payments, []); assert.equal(quote.choamIncome, null); }
|
||||
const done = advancePairedDiscoveryNexus(revealed, g => !g.battle && pairedDiscoveryNexusClean(g));
|
||||
assert.equal(done.phase, 6, 'Second physical conflict keeps unrelated Collection out of this invoice.');
|
||||
assert.equal(done.lastBattleContext!.winner, f.owner); assert.equal(player(done, f.owner).forces['shrine:0'], 5);
|
||||
assert.equal(player(done, f.owner).tanks, player(revealed, f.owner).tanks + 3);
|
||||
assert.equal(player(done, f.guild).tanks, player(revealed, f.guild).tanks + 6);
|
||||
assert.equal(player(done, f.native).spice, player(revealed, f.native).spice + (advanced ? 1 : 0));
|
||||
const bounty = player(revealed, f.guild).leaders.find(l => l.id === b.enemy)!.strength;
|
||||
assert.equal(player(done, f.owner).spice, player(revealed, f.owner).spice - (advanced ? 3 : 0) + bounty);
|
||||
assert.deepEqual(done.strongholdCards, revealed.strongholdCards); custody(done);
|
||||
});
|
||||
|
||||
void test('paired E2 CHOAM own Cunning uses original physical fuel and native response after the closing deal', () => {
|
||||
const f = createPairedDiscoveryNexusFixture({ family: 'choam' }), closing = closePairedDiscoveryNexus(f);
|
||||
let game = advancePairedDiscoveryNexus(closing.afterDraw, g => g.phase === 5 && g.active === f.native && pairedDiscoveryNexusClean(g));
|
||||
const fuel = holdPairedDiscoveryNexusCard(game, f.native, c => c.kind === 'projectile', f.staging);
|
||||
holdPairedDiscoveryNexusCard(game, f.guild, c => c.effect === 'karama', f.staging);
|
||||
const offer = viewGame(game, f.native).choamWorthless!.plays.find(p => p.source === 'nexus' && p.card.id === fuel.id && p.effect === 'kulon');
|
||||
assert.ok(offer && offer.source === 'nexus' && !offer.blocked);
|
||||
const request: Action = { type: 'card', mode: 'choam', card: fuel.id, effect: 'kulon', nexus: offer.event };
|
||||
const before = structuredClone(game); game = applyAction(game, f.native, request);
|
||||
assert.equal(game.response?.kind, 'choamWorthless'); assert.equal(game.nexusCards!.cards!.hands[f.native], null);
|
||||
assert.ok(player(game, f.native).hand.some(c => c.id === fuel.id));
|
||||
game = settlePairedDiscoveryNexusShipment(game);
|
||||
assert.equal(game.choamMovement!.bonus, 1); assert.equal(game.nexusChoamLast!.stage, 'complete');
|
||||
assert.equal(game.discard.filter(c => c.id === fuel.id).length, 1);
|
||||
assert.equal(game.nexusCards!.cards!.discard.filter(c => c === 'choam').length, 1);
|
||||
for (const p of before.players) { assert.equal(player(game, p.id).spice, p.spice); assert.deepEqual(player(game, p.id).forces, p.forces); }
|
||||
reject(game, f.native, request); custody(game);
|
||||
});
|
||||
|
||||
void test('all four minimal policies consume actual paired entry/vote/typed ride/closing draw and native Face Dance outcomes', () => {
|
||||
const f = createPairedDiscoveryNexusFixture({ family: 'ix' }), c = closePairedDiscoveryNexus(f);
|
||||
const b = preparePairedDiscoveryNexusBattle(f, c.afterDraw), revealed = revealPairedDiscoveryNexusBattle(b);
|
||||
const dance = advancePairedDiscoveryNexus(revealed, g => g.decision?.kind === 'faceDance');
|
||||
for (const difficulty of DIFFICULTIES) for (const current of [
|
||||
{ game: f.game, actor: f.collector, type: 'decision' },
|
||||
{ game: c.vote, actor: c.vote.decision!.player, type: 'decision' },
|
||||
{ game: c.ride.before, actor: f.fremen, type: 'decision' },
|
||||
{ game: c.drawing, actor: f.owner, type: 'nexusCardChoice' },
|
||||
{ game: dance, actor: f.native, type: 'decision' },
|
||||
]) {
|
||||
const view = viewGame(current.game, current.actor); view.players.find(p => p.id === current.actor)!.bot = difficulty;
|
||||
const action = botActions(view).find(a => a.type === current.type); assert.ok(action);
|
||||
const after = applyPairedDiscoveryNexusStep(structuredClone(current.game), { actor: current.actor, action });
|
||||
if (current.game.decision?.kind === 'discoveryEntry') {
|
||||
assert.equal(after.discoveryEntry, undefined);
|
||||
assert.equal(player(after, current.actor).spice, player(current.game, current.actor).spice);
|
||||
assert.equal(player(after, current.actor).reserves, player(current.game, current.actor).reserves);
|
||||
} else if (current.game.decision?.kind === 'greatMakerVote')
|
||||
assert.equal(after.greatMaker!.votes.length, current.game.greatMaker!.votes.length + 1);
|
||||
else if (current.game.decision?.kind === 'greatMakerRide') {
|
||||
assert.equal(after.greatMaker!.stage, 'complete');
|
||||
assert.equal(player(after, f.fremen).spice, player(current.game, f.fremen).spice);
|
||||
assert.equal(player(after, f.fremen).shipped, player(current.game, f.fremen).shipped);
|
||||
} else if (current.game.decision?.kind === 'faceDance') {
|
||||
assert.notEqual(after.decision?.kind, 'faceDance');
|
||||
assert.deepEqual(after.techTokens, current.game.techTokens);
|
||||
if (action.reveal) {
|
||||
assert.equal(player(after, f.owner).forces['shrine:0'] ?? 0, 0);
|
||||
assert.equal(player(after, f.owner).leaders.find(l => l.id === b.leader)!.dead, true);
|
||||
assert.ok((player(after, f.native).forces['shrine:0'] ?? 0) > 0);
|
||||
} else assert.deepEqual(player(after, f.owner).forces, player(current.game, f.owner).forces);
|
||||
} else {
|
||||
assert.ok(after.nexusCards!.phase!.done.includes(current.actor));
|
||||
assert.ok(after.nexusCards!.cards!.hands[current.actor]);
|
||||
assert.equal(after.nexusCards!.cards!.deck.length, current.game.nexusCards!.cards!.deck.length - 1);
|
||||
}
|
||||
custody(after);
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four minimal policies settle the original signed E2 pair and materialize only physical reserves', () => {
|
||||
const f = createPairedDiscoveryNexusFixture({ family: 'choam' }), c = closePairedDiscoveryNexus(f);
|
||||
const window = richeseWindow(f, c.afterDraw), request = pairedDiscoveryRicheseRequest(f, window);
|
||||
holdPairedDiscoveryNexusCard(window, f.guild, card => card.effect === 'karama', f.staging);
|
||||
const pending = applyAction(window, f.owner, request);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(pending, f.guild);
|
||||
view.players.find(p => p.id === f.guild)!.bot = difficulty;
|
||||
const action = botActions(view).find(a => a.type === 'passResponse' || a.type === 'card');
|
||||
assert.ok(action, 'The actual eligible Karama holder must resolve the signed pair response.');
|
||||
let game = applyAction(pending, f.guild, action);
|
||||
game = settlePairedDiscoveryNexusShipment(game);
|
||||
assert.equal(game.nexusCards!.cards!.discard.filter(c => c === 'richese').length, 1);
|
||||
if (game.nexusRicheseCunningLast!.stage === 'stopped') {
|
||||
assert.equal(player(game, f.owner).spice, player(window, f.owner).spice);
|
||||
assert.equal(player(game, f.owner).reserves, player(window, f.owner).reserves);
|
||||
assert.deepEqual(player(game, f.owner).forces, player(window, f.owner).forces);
|
||||
assert.deepEqual(player(game, f.owner).noField, player(window, f.owner).noField);
|
||||
assert.equal(player(game, f.owner).shipped, false);
|
||||
custody(game);
|
||||
} else {
|
||||
assert.equal(game.nexusRicheseCunningLast!.stage, 'shipped');
|
||||
assert.equal(player(game, f.owner).forces['shrine:0'], 5);
|
||||
assert.equal(player(game, f.owner).reserves, player(window, f.owner).reserves - 5);
|
||||
assert.equal(player(game, f.owner).spice, player(window, f.owner).spice - 1);
|
||||
const materialized = richeseReveal(game, f.owner);
|
||||
assert.equal(player(materialized, f.owner).forces['shrine:0'], 8);
|
||||
assert.equal(player(materialized, f.owner).reserves, player(window, f.owner).reserves - 8);
|
||||
custody(materialized);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (const family of ['ix', 'choam'] as const) void test(`paired ${family} authenticated original setup retains native actors, starting hands/offers and exposes a genuine human entry window`, () => {
|
||||
const factions = family === 'ix' ? ['ixians', 'tleilaxu', 'guild', 'fremen'] as const : ['richese', 'choam', 'guild', 'fremen'] as const;
|
||||
const lobby = createGame('PAIREDAUTHDISCOVERY', newPlayer(`auth-${factions[0]}`, factions[0], factions[0]), true, [family]);
|
||||
for (const faction of factions.slice(1)) joinGame(lobby, newPlayer(`auth-${faction}`, faction, faction));
|
||||
const setup = initializePairedDiscoveryNexusForFixture({ initial: lobby, family, tech: true, strongholds: true });
|
||||
const original = structuredClone(setup), f = createPairedDiscoveryNexusFixture({ initial: setup });
|
||||
assert.deepEqual(setup, original); assert.deepEqual(f.setup, original);
|
||||
assert.equal(f.owner, `auth-${factions[0]}`); assert.equal(f.native, `auth-${factions[1]}`);
|
||||
for (const p of setup.players) for (const card of p.hand)
|
||||
assert.ok(player(f.afterSetup, p.id).hand.some(c => c.id === card.id), 'Continue the native starting hand, not a cleared/redealt approximation.');
|
||||
assert.equal(f.game.decision!.player, family === 'ix' ? 'auth-ixians' : 'auth-guild');
|
||||
const offer = viewGame(f.game, f.collector).discoveryEntry!;
|
||||
const accepted = applyAction(f.game, f.collector, { type: 'decision', event: offer.event, accept: true,
|
||||
groups: [{ source: f.source, normal: 1, elite: family === 'ix' ? 1 : 0 }] });
|
||||
assert.equal(player(accepted, f.collector).forces['shrine:0'], family === 'ix' ? 2 : 1); custody(accepted);
|
||||
});
|
||||
@@ -0,0 +1,318 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer,
|
||||
viewGame, type Action, type Game, type Player,
|
||||
} from '../game/engine';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
|
||||
import { classicNexusLeaderSkillsProfile } from '../game/leader-skill-profile';
|
||||
import { DISCOVERY_CARD_PLACEMENTS } from '../game/discoveries';
|
||||
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
|
||||
import { createStrongholdCards } from '../game/stronghold-cards';
|
||||
import {
|
||||
advanceClassicDiscoveryNexus, applyClassicDiscoveryNexusStep, classicDiscoveryNexusClean,
|
||||
finishClassicDiscoveryNexusMentat, formClassicDiscoveryNexusAlliance,
|
||||
nextClassicDiscoveryNexusStep, openClassicDiscoveryNexusAlliance, revealClassicDiscoveryNexus,
|
||||
voteClassicDiscoveryNexus, withClassicDiscoveryNexusLottery, withClassicDiscoveryNexusToken,
|
||||
} from './fixture-discovery-classic-nexus';
|
||||
import { holdNexusSkillsModulesPaymentsCard } from './fixture-nexus-skills-modules-payments';
|
||||
|
||||
export type DiscoveryClassicNexusSkillsStep = { actor: string; action: Action };
|
||||
export type DiscoveryClassicNexusSkillsOptions = {
|
||||
/** Authenticated ready lobby or ORIGINAL unassigned turn-one setup, never a played save. */
|
||||
initial?: Game;
|
||||
gameId?: string;
|
||||
seatIds?: string[];
|
||||
seats?: 2 | 3 | 4 | 5 | 6;
|
||||
factions?: FactionId[];
|
||||
advanced?: boolean;
|
||||
tech?: boolean;
|
||||
strongholds?: boolean;
|
||||
/** Only a new, locally generated setup may position its physical offer lottery. */
|
||||
skill?: LeaderSkillId;
|
||||
};
|
||||
export type DiscoveryClassicNexusSkillsFixture = {
|
||||
initial: Game;
|
||||
setup: Game;
|
||||
trained: Game;
|
||||
collection: Game;
|
||||
firstMentat: { before: Game; step: DiscoveryClassicNexusSkillsStep; after: Game };
|
||||
game: Game;
|
||||
owner: string;
|
||||
opponent: string;
|
||||
leader: string;
|
||||
skill: LeaderSkillId;
|
||||
token: string;
|
||||
source: string;
|
||||
wormSource: string;
|
||||
actions: DiscoveryClassicNexusSkillsStep[];
|
||||
staging: string[];
|
||||
};
|
||||
export type DiscoveryClassicNexusSkillsClosing = {
|
||||
vote: Game;
|
||||
alliance: Game;
|
||||
beforeDraw: Game;
|
||||
drawStep: DiscoveryClassicNexusSkillsStep;
|
||||
game: Game;
|
||||
};
|
||||
export type DiscoveryClassicNexusSkillsBattle = {
|
||||
shipmentWindow: Game;
|
||||
shipmentStep: DiscoveryClassicNexusSkillsStep;
|
||||
arrived: Game;
|
||||
game: Game;
|
||||
weapon: string;
|
||||
plans: DiscoveryClassicNexusSkillsStep[];
|
||||
};
|
||||
export const discoveryClassicNexusSkillsClean = classicDiscoveryNexusClean;
|
||||
export const advanceDiscoveryClassicNexusSkills = advanceClassicDiscoveryNexus;
|
||||
export const stepDiscoveryClassicNexusSkills = applyClassicDiscoveryNexusStep;
|
||||
export function discoveryClassicNexusSkillsPlayer(game: Game, actor: string): Player {
|
||||
const seat = game.players.find(player => player.id === actor);
|
||||
assert.ok(seat); return seat;
|
||||
}
|
||||
|
||||
/** The thirteen scalar draws belong only to the original all14 shuffle. Native
|
||||
* UUID bytes and subsequent original component production remain unchanged. */
|
||||
function offeredSkills<T>(selected: LeaderSkillId[], operation: () => T): T {
|
||||
const working = LEADER_SKILL_CARDS.map(card => card.id);
|
||||
const remainder = working.filter(skill => !selected.includes(skill));
|
||||
const desired = selected.flatMap(skill => [skill, remainder.shift()!]);
|
||||
desired.push(...remainder);
|
||||
const rolls: number[] = [];
|
||||
for (let index = working.length - 1; index > 0; index--) {
|
||||
const swap = working.indexOf(desired[index]); assert.ok(swap >= 0 && swap <= index);
|
||||
rolls.push(Math.floor((swap + 0.5) / (index + 1) * 0x100000000));
|
||||
[working[index], working[swap]] = [working[swap], working[index]];
|
||||
}
|
||||
const native = crypto.getRandomValues.bind(crypto);
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
let cursor = 0;
|
||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||
if (array instanceof Uint32Array && array.length === 1) array[0] = rolls[cursor++] ?? 0xffffffff;
|
||||
else Reflect.apply(native, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try { return operation(); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
|
||||
export function initializeDiscoveryClassicNexusSkillsSetup(options: DiscoveryClassicNexusSkillsOptions = {}): Game {
|
||||
let game: Game;
|
||||
if (options.initial) game = structuredClone(options.initial);
|
||||
else {
|
||||
const factions = options.factions ?? ['atreides', 'harkonnen', 'beneGesserit', 'guild', 'emperor', 'fremen'];
|
||||
const count = options.seats ?? options.seatIds?.length ?? options.factions?.length ?? 3;
|
||||
assert.ok(count >= 2 && count <= 6 && factions.length >= count);
|
||||
if (options.seatIds) assert.equal(options.seatIds.length, count);
|
||||
game = createGame(options.gameId ?? 'DISCOVERYCLASSICNEXUSSKILLS',
|
||||
newPlayer(options.seatIds?.[0] ?? factions[0], factions[0], factions[0]), options.advanced ?? false);
|
||||
for (let index = 1; index < count; index++)
|
||||
joinGame(game, newPlayer(options.seatIds?.[index] ?? factions[index], factions[index], factions[index]));
|
||||
}
|
||||
if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced);
|
||||
assert.deepEqual(game.expansions, []);
|
||||
if (game.status === 'lobby') {
|
||||
assert.ok(!game.leaderSkills && !game.discoveries);
|
||||
if (options.tech && !game.techTokens) game = applyAction(game, game.host, { type: 'techTokens', enabled: true });
|
||||
if (options.strongholds && !game.strongholdCards) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: true });
|
||||
game.discoveryEnabled = true;
|
||||
game.nexusCards ??= { cards: null, phase: null };
|
||||
assert.deepEqual(game.nexusCards, { cards: null, phase: null });
|
||||
assert.ok(classicNexusLeaderSkillsProfile(game));
|
||||
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
|
||||
// A supplied human lobby must keep the real lottery; never demand one skill.
|
||||
if (options.initial) game = withClassicDiscoveryNexusLottery(() => initializeLeaderSkillsGameForAudit(game));
|
||||
else {
|
||||
const requested = options.skill ?? 'warmaster';
|
||||
const passive: LeaderSkillId[] = ['sandmaster', 'prana-bindu-adept', 'killer-medic', 'master-of-assassins', 'swordmaster-of-ginaz', 'planetologist']
|
||||
.filter(skill => skill !== requested) as LeaderSkillId[];
|
||||
const selected = game.players.map((_, index) => index === 0 ? requested : passive.shift()!);
|
||||
game = offeredSkills(selected, () => initializeLeaderSkillsGameForAudit(game));
|
||||
}
|
||||
}
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.turn, 1); assert.equal(game.phase, 0);
|
||||
assert.ok(['prediction', 'leaderSkills'].includes(game.setupStage!));
|
||||
assert.ok(game.leaderSkills && game.discoveries && game.nexusCards?.cards);
|
||||
assert.ok(classicNexusLeaderSkillsProfile(game));
|
||||
assert.equal(game.leaderSkills.assignments.length, 0);
|
||||
assert.ok(game.players.every(player => !player.traitors.length && !player.traitorChoices.length));
|
||||
if (game.strongholdCards) assert.deepEqual(game.strongholdCards, createStrongholdCards());
|
||||
if (options.tech !== undefined) assert.equal(!!game.techTokens, options.tech);
|
||||
if (options.strongholds !== undefined) assert.equal(!!game.strongholdCards, options.strongholds);
|
||||
return game;
|
||||
}
|
||||
|
||||
function reserveBoard(game: Game, staging: string[]): void {
|
||||
for (const player of game.players) {
|
||||
player.reserves += Object.values(player.forces).reduce((sum, amount) => sum + amount, 0);
|
||||
player.forces = {};
|
||||
if (player.elites) {
|
||||
player.elites.reserves += Object.values(player.elites.forces).reduce((sum, amount) => sum + amount, 0);
|
||||
player.elites.forces = {};
|
||||
}
|
||||
if (player.advisors) player.advisors = {};
|
||||
}
|
||||
staging.push('Conserved original board counters into their OWN reserves before actual movement completion; no death, wallet, clock, alliance or earned receipt assigned.');
|
||||
}
|
||||
export function positionDiscoveryClassicNexusSkillsOrdinary(game: Game, actor: string, key: string, amount: number, staging: string[]): void {
|
||||
const player = discoveryClassicNexusSkillsPlayer(game, actor);
|
||||
assert.ok(player.reserves - (player.elites?.reserves ?? 0) >= amount);
|
||||
player.reserves -= amount; player.forces[key] = (player.forces[key] ?? 0) + amount;
|
||||
staging.push(`Conserved ${amount} original ordinary ${actor} reserve counters at ${key}; subsequent native actions alone determine survival.`);
|
||||
}
|
||||
export function orderDiscoveryClassicNexusSkillsSpice(game: Game, secondTurn: boolean, staging: string[]): void {
|
||||
const take = (matches: (card: Game['spiceDeck'][number]) => boolean) => {
|
||||
const index = game.spiceDeck.findIndex(matches); assert.ok(index >= 0);
|
||||
return game.spiceDeck.splice(index, 1)[0];
|
||||
};
|
||||
const front: Game['spiceDeck'] = [];
|
||||
if (secondTurn) front.push(take(card => 'worm' in card && !!card.greatMaker));
|
||||
front.push(take(card => 'territory' in card && (secondTurn ? !card.discovery && card.territory === 'broken_land'
|
||||
: card.discovery === 'discovery-hagga-basin')));
|
||||
if (game.advanced) front.push(take(card => 'territory' in card && !card.discovery &&
|
||||
(secondTurn ? card.territory !== 'broken_land' : card.territory === 'rock_outcroppings')));
|
||||
game.spiceDeck.unshift(...front);
|
||||
staging.push(secondTurn ? 'Conserved original unplayed Great Maker followed by BOTH Advanced land replacements; original discard history untouched.'
|
||||
: 'Conserved original undealt Hagga Discovery and optional Advanced Rock Outcroppings positions; original token lottery selects Cistern.');
|
||||
}
|
||||
|
||||
/** Human endpoint is actual trained next-turn free entry BEFORE Storm. Supplied
|
||||
* offers/hands/IDs survive verbatim; only source-clear legal offered training is
|
||||
* selected. Original owner clocks, reveal and first Mentat own every transition. */
|
||||
export function createDiscoveryClassicNexusSkillsFixture(options: DiscoveryClassicNexusSkillsOptions = {}): DiscoveryClassicNexusSkillsFixture {
|
||||
const setup = initializeDiscoveryClassicNexusSkillsSetup(options);
|
||||
const initial = options.initial ? structuredClone(options.initial) : structuredClone(setup);
|
||||
const owner = setup.players[0].id, opponent = setup.players[1].id;
|
||||
const actions: DiscoveryClassicNexusSkillsStep[] = [], staging: string[] = [];
|
||||
let game = structuredClone(setup);
|
||||
while (game.status === 'setup') {
|
||||
let step: DiscoveryClassicNexusSkillsStep;
|
||||
if (game.setupStage === 'leaderSkills') {
|
||||
const actor = Object.keys(game.leaderSkills!.offers)[0], view = viewGame(game, actor).leaderSkills!;
|
||||
const legal = view.offer!.cards.filter(skill => !view.unavailableSkills?.[skill]);
|
||||
const desired = !options.initial && actor === owner ? options.skill ?? 'warmaster' : undefined;
|
||||
const skill = desired && legal.includes(desired) ? desired : legal[0];
|
||||
const eligible = new Set(view.eligibleLeaders.map(leader => leader.id));
|
||||
const leader = discoveryClassicNexusSkillsPlayer(game, actor).leaders.filter(leader => eligible.has(leader.id))
|
||||
.sort((a, b) => b.strength - a.strength)[0];
|
||||
assert.ok(skill && leader, 'Human continuation requires genuinely offered legal training, never a substitute offer.');
|
||||
step = { actor, action: { type: 'leaderSkill', event: view.offer!.event, skill, leader: leader.id } };
|
||||
} else { const next = nextClassicDiscoveryNexusStep(game); assert.ok(next); step = next; }
|
||||
game = applyClassicDiscoveryNexusStep(game, step, actions);
|
||||
}
|
||||
const trained = structuredClone(game), assignment = game.leaderSkills!.assignments.find(item => item.owner === owner)!;
|
||||
game = advanceClassicDiscoveryNexus(game, state => state.phase === 1 && classicDiscoveryNexusClean(state), actions);
|
||||
orderDiscoveryClassicNexusSkillsSpice(game, false, staging);
|
||||
for (let count = 0; !game.discoveries!.tokens.some(token => token.face === 'cistern' && token.status === 'placed') && count < 100; count++) {
|
||||
const next = nextClassicDiscoveryNexusStep(game); assert.ok(next); actions.push(structuredClone(next));
|
||||
game = withClassicDiscoveryNexusToken(game, 'cistern', () => applyAction(game, next.actor, next.action));
|
||||
}
|
||||
assert.ok(game.discoveries!.tokens.some(token => token.face === 'cistern' && token.status === 'placed'));
|
||||
game = advanceClassicDiscoveryNexus(game, state => state.phase === 5 && classicDiscoveryNexusClean(state), actions);
|
||||
reserveBoard(game, staging);
|
||||
const placement = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'];
|
||||
const source = `${placement.territory}:${placement.sector}`, wormSource = 'hagga_basin:12';
|
||||
positionDiscoveryClassicNexusSkillsOrdinary(game, owner, source, 3, staging);
|
||||
positionDiscoveryClassicNexusSkillsOrdinary(game, owner, wormSource, 6, staging);
|
||||
if (game.strongholdCards) positionDiscoveryClassicNexusSkillsOrdinary(game, owner, 'arrakeen:10', 1, staging);
|
||||
game = advanceClassicDiscoveryNexus(game, state => state.phase === 7 && classicDiscoveryNexusClean(state), actions);
|
||||
const collection = structuredClone(game), token = game.discoveries!.tokens.find(token => token.face === 'cistern')!;
|
||||
game = revealClassicDiscoveryNexus(game, owner, token.id, actions);
|
||||
const firstMentat = finishClassicDiscoveryNexusMentat(game, actions); game = firstMentat.after;
|
||||
assert.equal(game.turn, 2); assert.equal(game.decision?.kind, 'discoveryEntry'); assert.equal(game.decision!.player, owner);
|
||||
return { initial, setup, trained, collection, firstMentat, game, owner, opponent,
|
||||
leader: assignment.leader, skill: assignment.skill, token: token.id, source, wormSource, actions, staging };
|
||||
}
|
||||
export function enterDiscoveryClassicNexusSkills(fixture: DiscoveryClassicNexusSkillsFixture, amount = 3): Game {
|
||||
const game = structuredClone(fixture.game), view = viewGame(game, fixture.owner);
|
||||
const source = view.discoveryEntry!.sources.find(source => source.normal >= amount); assert.ok(source);
|
||||
const action = discoveryEntryMoveAction(view, [{ source: source.source, normal: amount, elite: 0 }]); assert.ok(action);
|
||||
return applyClassicDiscoveryNexusStep(game, { actor: fixture.owner, action }, fixture.actions);
|
||||
}
|
||||
|
||||
/** Actual Great Maker death -> all votes -> reserve ride -> settled alliance ->
|
||||
* ENTIRE Spice Blow closure. Omitting target preserves the genuine original draw
|
||||
* for human continuation; targeting only reorders an unplayed deck singleton. */
|
||||
export function closeDiscoveryClassicNexusSkills(fixture: DiscoveryClassicNexusSkillsFixture, target?: FactionId): DiscoveryClassicNexusSkillsClosing {
|
||||
assert.ok(fixture.game.players.length >= 3, 'An unallied draw needs two other actual alliance seats.');
|
||||
let game = enterDiscoveryClassicNexusSkills(fixture);
|
||||
game = advanceClassicDiscoveryNexus(game, state => state.phase === 1 && classicDiscoveryNexusClean(state), fixture.actions);
|
||||
orderDiscoveryClassicNexusSkillsSpice(game, true, fixture.staging);
|
||||
game = advanceClassicDiscoveryNexus(game, state => state.greatMaker?.stage === 'vote', fixture.actions);
|
||||
const vote = structuredClone(game);
|
||||
game = voteClassicDiscoveryNexus(game, game.players.map(() => true), fixture.actions);
|
||||
game = openClassicDiscoveryNexusAlliance(game);
|
||||
const others = game.players.filter(player => player.id !== fixture.owner);
|
||||
game = formClassicDiscoveryNexusAlliance(game, others[0].id, others[1].id);
|
||||
const alliance = structuredClone(game);
|
||||
game = advanceClassicDiscoveryNexus(game, state => state.nexusCards?.phase?.stage === 'drawing', fixture.actions);
|
||||
const cards = game.nexusCards!.cards!;
|
||||
if (target) {
|
||||
const index = cards.deck.indexOf(target); assert.ok(index >= 0, 'Only an original unplayed singleton may supply the targeted draw.');
|
||||
cards.deck.unshift(cards.deck.splice(index, 1)[0]);
|
||||
fixture.staging.push(`Positioned original UNPLAYED ${target} Nexus singleton before actual closing draw; never a held-hand assignment.`);
|
||||
}
|
||||
const beforeDraw = structuredClone(game), drawStep: DiscoveryClassicNexusSkillsStep = { actor: fixture.owner,
|
||||
action: { type: 'nexusCardChoice', turn: game.turn, card: cards.hands[fixture.owner], choice: 'draw', ownRedraws: 0 } };
|
||||
game = applyClassicDiscoveryNexusStep(game, drawStep, fixture.actions);
|
||||
return { vote, alliance, beforeDraw, drawStep, game };
|
||||
}
|
||||
|
||||
/** Borrowed Guild tariff is spent through an actual nested shipment. The trained
|
||||
* Warmaster remains the physical actor; Arrakeen custody is not nested support. */
|
||||
export function openDiscoveryClassicNexusSkillsBattle(fixture: DiscoveryClassicNexusSkillsFixture, state: Game): DiscoveryClassicNexusSkillsBattle {
|
||||
assert.equal(fixture.skill, 'warmaster');
|
||||
let game = advanceClassicDiscoveryNexus(state, game => game.phase === 5 && game.active === fixture.owner && classicDiscoveryNexusClean(game), fixture.actions);
|
||||
const shipmentWindow = structuredClone(game), offer = viewGame(game, fixture.owner).nexusGuildSecretAlly;
|
||||
assert.ok(offer && !offer.blocked);
|
||||
const shipmentStep: DiscoveryClassicNexusSkillsStep = { actor: fixture.owner, action: {
|
||||
type: 'ship', territory: 'cistern', sector: 0, amount: 3, elite: 0, nexus: offer.event } };
|
||||
game = applyClassicDiscoveryNexusStep(game, shipmentStep, fixture.actions);
|
||||
game = advanceClassicDiscoveryNexus(game, game => !game.pendingShipment && classicDiscoveryNexusClean(game), fixture.actions);
|
||||
const arrived = structuredClone(game);
|
||||
positionDiscoveryClassicNexusSkillsOrdinary(game, fixture.opponent, 'cistern:0', 2, fixture.staging);
|
||||
const weapon = holdNexusSkillsModulesPaymentsCard(game, fixture.owner, 'worthless', fixture.staging).id;
|
||||
const enemy = discoveryClassicNexusSkillsPlayer(game, fixture.opponent).leaders.filter(leader => !leader.dead && !leader.usedAt &&
|
||||
!game.leaderSkills!.assignments.some(assignment => assignment.leader === leader.id)).sort((a, b) => a.strength - b.strength)[0];
|
||||
assert.ok(enemy);
|
||||
game = advanceClassicDiscoveryNexus(game, game => game.phase === 6 && classicDiscoveryNexusClean(game), fixture.actions);
|
||||
const actor = game.active!; assert.ok(actor === fixture.owner || actor === fixture.opponent);
|
||||
game = applyClassicDiscoveryNexusStep(game, { actor, action: { type: 'chooseBattle', territory: 'cistern',
|
||||
target: actor === fixture.owner ? fixture.opponent : fixture.owner } }, fixture.actions);
|
||||
for (let count = 0; count < 160; count++) {
|
||||
const next = nextClassicDiscoveryNexusStep(game);
|
||||
if (!next) break;
|
||||
if (next.action.type === 'leaderSkillVisibility') next.action.hide = next.actor === fixture.owner;
|
||||
game = applyClassicDiscoveryNexusStep(game, next, fixture.actions);
|
||||
}
|
||||
assert.ok(game.battle && !game.battle.revealed && classicDiscoveryNexusClean(game));
|
||||
return { shipmentWindow, shipmentStep, arrived, game, weapon, plans: [
|
||||
{ actor: fixture.owner, action: { type: 'battlePlan', dial: 1, support: game.advanced ? 1 : 0,
|
||||
leader: fixture.leader, weapon, defense: null } },
|
||||
{ actor: fixture.opponent, action: { type: 'battlePlan', dial: 0, support: 0, leader: enemy.id, weapon: null, defense: null } },
|
||||
] };
|
||||
}
|
||||
export function revealDiscoveryClassicNexusSkillsBattle(battle: DiscoveryClassicNexusSkillsBattle): Game {
|
||||
let game = structuredClone(battle.game);
|
||||
for (const step of battle.plans) {
|
||||
game = applyClassicDiscoveryNexusStep(game, step);
|
||||
while (game.response || game.phaseOpening || game.decision?.kind === 'fullPlanOffer') {
|
||||
const next = nextClassicDiscoveryNexusStep(game); assert.ok(next); game = applyClassicDiscoveryNexusStep(game, next);
|
||||
}
|
||||
}
|
||||
assert.ok(game.battle?.revealed); return game;
|
||||
}
|
||||
export function finishDiscoveryClassicNexusSkillsBattle(state: Game, stopAtCleanup = false): Game {
|
||||
let game = structuredClone(state);
|
||||
for (let count = 0; count < 240; count++) {
|
||||
if (stopAtCleanup && game.decision?.kind === 'battleCards') return game;
|
||||
if (!game.battle && classicDiscoveryNexusClean(game) && !game.pendingTreacheryDiscard) return game;
|
||||
const next = nextClassicDiscoveryNexusStep(game); assert.ok(next);
|
||||
if (next.action.type === 'traitorCall') next.action.call = false;
|
||||
game = applyClassicDiscoveryNexusStep(game, next);
|
||||
}
|
||||
throw Error('Original trained nested battle did not finish its cleanup.');
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, handLimit, initializePairedNexusGameForAudit, joinGame, newPlayer,
|
||||
viewGame, type Action, type Game, type Player,
|
||||
} from '../game/engine';
|
||||
import { MOBILE_LOCATION, gameTerritories, territory, validGameLocation } from '../game/board';
|
||||
import { isAuditorLeader, type Card } from '../game/cards';
|
||||
import { DISCOVERY_CARD_PLACEMENTS } from '../game/discoveries';
|
||||
import { greatMakerRideAction } from '../game/great-maker-options';
|
||||
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 PairedDiscoveryNexusStep = { actor: string; action: Action };
|
||||
export type PairedDiscoveryNexusOptions = {
|
||||
/** Authenticated original ready lobby or original unassigned turn-one setup. */
|
||||
initial?: Game;
|
||||
family?: 'ix' | 'choam';
|
||||
advanced?: boolean;
|
||||
tech?: boolean;
|
||||
strongholds?: boolean;
|
||||
};
|
||||
export type PairedDiscoveryNexusTransition = { before: Game; step: PairedDiscoveryNexusStep; after: Game };
|
||||
export type PairedDiscoveryNexusFixture = {
|
||||
initial: Game; setup: Game; afterSetup: Game; collection: Game;
|
||||
firstMentat: PairedDiscoveryNexusTransition; game: Game;
|
||||
family: 'ix' | 'choam'; owner: string; native: string; collector: string;
|
||||
guild: string; fremen: string; token: string; source: string; claim: 'arrakeen' | 'carthag';
|
||||
actions: PairedDiscoveryNexusStep[]; staging: string[];
|
||||
};
|
||||
export type PairedDiscoveryNexusClosing = {
|
||||
entry: PairedDiscoveryNexusTransition; vote: Game; ride: PairedDiscoveryNexusTransition;
|
||||
alliance: Game; drawing: Game; afterDraw: Game;
|
||||
};
|
||||
export type PairedDiscoveryNexusBattle = {
|
||||
game: Game; beforeBattle: Game; battleStep: PairedDiscoveryNexusStep;
|
||||
winner: string; loser: string; leader: string; enemy: string;
|
||||
weapon: string; defense: string; source: string; plans: PairedDiscoveryNexusStep[];
|
||||
};
|
||||
export const pairedDiscoveryNexusClean = (game: Game): boolean =>
|
||||
!game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
|
||||
export function pairedDiscoveryNexusPlayer(game: Game, actor: string): Player {
|
||||
const p = game.players.find(p => p.id === actor); assert.ok(p); return p;
|
||||
}
|
||||
|
||||
/** Do not automate owned human entry, vote, ride, declarations or battle plans
|
||||
* in the returned programmes. These defaults only cross unrelated native windows. */
|
||||
export function nextPairedDiscoveryNexusStep(game: Game): PairedDiscoveryNexusStep | null {
|
||||
if (!game.phaseOpening && !game.response && game.decision) {
|
||||
const d = game.decision;
|
||||
if (d.kind === 'discoveryEntry' || d.kind === 'greatMakerRide')
|
||||
return { actor: d.player, action: { type: 'decision', event: d.event, accept: false } };
|
||||
if (d.kind === 'greatMakerVote')
|
||||
return { actor: d.player, action: { type: 'decision', event: d.event, yes: false } };
|
||||
if (d.kind === 'faceDance') return null;
|
||||
if (d.kind === 'discoveryDiscard') {
|
||||
const card = pairedDiscoveryNexusPlayer(game, d.player).hand[0]; assert.ok(card);
|
||||
return { actor: d.player, action: { type: 'decision', event: d.event, card: card.id } };
|
||||
}
|
||||
}
|
||||
return game.expansions[0] === 'ix' ? nextPairedIxNexusModulesStep(game) : nextPairedChoamStep(game);
|
||||
}
|
||||
export function applyPairedDiscoveryNexusStep(game: Game, step: PairedDiscoveryNexusStep,
|
||||
actions?: PairedDiscoveryNexusStep[]): Game {
|
||||
actions?.push(structuredClone(step));
|
||||
return withClassicDiscoveryNexusLottery(() => applyAction(game, step.actor, step.action));
|
||||
}
|
||||
export function advancePairedDiscoveryNexus(state: Game, until: (g: Game) => boolean,
|
||||
actions?: PairedDiscoveryNexusStep[]): Game {
|
||||
let game = structuredClone(state);
|
||||
for (let n = 0; n < 2400; n++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished');
|
||||
const step = nextPairedDiscoveryNexusStep(game);
|
||||
assert.ok(step, 'This original paired programme needs its human plan or Face Dance.');
|
||||
game = applyPairedDiscoveryNexusStep(game, step, actions);
|
||||
}
|
||||
throw Error('Paired Discovery/Nexus continuation did not reach the requested original boundary');
|
||||
}
|
||||
|
||||
/** Never restart the supplied setup, clear its original hands, replace its
|
||||
* private offers, assign actors, clocks, training, or manufacture a held card. */
|
||||
export function initializePairedDiscoveryNexusForFixture(options: PairedDiscoveryNexusOptions = {}): Game {
|
||||
const family = options.family ?? (options.initial?.expansions[0] === 'choam' ? 'choam' : 'ix');
|
||||
const factions = family === 'ix' ? ['ixians', 'tleilaxu', 'guild', 'fremen'] as const
|
||||
: ['richese', 'choam', 'guild', 'fremen'] as const;
|
||||
const advanced = options.advanced ?? options.initial?.advanced ?? true;
|
||||
const tech = options.tech ?? (options.initial ? !!options.initial.techTokens : true);
|
||||
const strongholds = options.strongholds ?? (options.initial ? !!options.initial.strongholdCards : advanced);
|
||||
let game = options.initial ? structuredClone(options.initial)
|
||||
: createGame('PAIREDDISCOVERYNEXUS', newPlayer(factions[0], factions[0], factions[0]), advanced, [family]);
|
||||
if (!options.initial) for (const faction of factions.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
|
||||
assert.deepEqual(game.players.map(p => p.faction).sort(), [...factions].sort());
|
||||
assert.deepEqual(game.expansions, [family]); assert.equal(game.advanced, advanced);
|
||||
assert.ok(!game.homeworlds && !game.leaderSkills && !game.ecazTreachery);
|
||||
assert.ok(game.status === 'lobby' || game.status === 'setup' && game.turn === 1 && game.phase === 0 &&
|
||||
game.players.every(p => !p.traitors.length && p.leaders.every(l => !l.dead && !l.usedAt)),
|
||||
'Use the original lobby or unassigned turn-one native setup, including any native starting hand.');
|
||||
if (game.status === 'lobby') {
|
||||
if (!!game.techTokens !== tech) game = applyAction(game, game.host, { type: 'techTokens', enabled: tech });
|
||||
if (!!game.strongholdCards !== strongholds) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: strongholds });
|
||||
game.discoveryEnabled = true;
|
||||
game.nexusCards ??= { cards: null, phase: null };
|
||||
for (const p of game.players) if (!p.ready) game = applyAction(game, p.id, { type: 'ready' });
|
||||
game = options.initial ? initializePairedNexusGameForAudit(game)
|
||||
: withClassicDiscoveryNexusLottery(() => initializePairedNexusGameForAudit(game));
|
||||
}
|
||||
assert.equal(game.discoveryEnabled, true); assert.ok(game.discoveries && game.nexusCards?.cards);
|
||||
assert.equal(!!game.techTokens, tech); assert.equal(!!game.strongholdCards, strongholds);
|
||||
return game;
|
||||
}
|
||||
function orderSpice(game: Game): void {
|
||||
const take = (matches: (card: Game['spiceDeck'][number]) => boolean) => {
|
||||
const at = game.spiceDeck.findIndex(matches); assert.ok(at >= 0);
|
||||
return game.spiceDeck.splice(at, 1)[0];
|
||||
};
|
||||
const cards = [take(c => 'territory' in c && c.discovery === 'discovery-hagga-basin')];
|
||||
if (game.advanced) cards.push(take(c => 'territory' in c && !c.discovery && c.territory === 'broken_land'));
|
||||
cards.push(take(c => 'worm' in c && !!c.greatMaker));
|
||||
cards.push(take(c => 'territory' in c && c.discovery === 'discovery-sihaya-ridge'));
|
||||
if (game.advanced) cards.push(take(c => 'territory' in c && !c.discovery && c.territory === 'rock_outcroppings'));
|
||||
game.spiceDeck.unshift(...cards);
|
||||
}
|
||||
export function reservePairedDiscoveryNexusBoard(game: Game, kept: string[] = []): void {
|
||||
for (const p of game.players) for (const [key, count] of Object.entries(p.forces)) {
|
||||
if (kept.includes(key)) continue;
|
||||
p.reserves += count; delete p.forces[key];
|
||||
if (p.elites) { p.elites.reserves += p.elites.forces[key] ?? 0; delete p.elites.forces[key]; }
|
||||
}
|
||||
}
|
||||
/** Explicitly conserved original subtype groups; elite is a subset of total. */
|
||||
export function placePairedDiscoveryNexus(game: Game, actor: string, key: string, total: number, elite = 0): void {
|
||||
const p = pairedDiscoveryNexusPlayer(game, actor);
|
||||
assert.ok(total >= elite && p.reserves - (p.elites?.reserves ?? 0) >= total - elite &&
|
||||
(p.elites?.reserves ?? 0) >= elite);
|
||||
p.reserves -= total; p.forces[key] = (p.forces[key] ?? 0) + total;
|
||||
if (elite) { p.elites!.reserves -= elite; p.elites!.forces[key] = (p.elites!.forces[key] ?? 0) + elite; }
|
||||
}
|
||||
export function holdPairedDiscoveryNexusCard(game: Game, actor: string, matches: (c: Card) => boolean,
|
||||
staging?: string[]): Card {
|
||||
const p = pairedDiscoveryNexusPlayer(game, actor), held = p.hand.find(matches);
|
||||
if (held) return held;
|
||||
const at = game.deck.findIndex(matches); assert.ok(at >= 0 && p.hand.length < handLimit(p));
|
||||
const card = game.deck.splice(at, 1)[0]; p.hand.push(card);
|
||||
staging?.push(`Conserved unplayed ${card.id}: original undealt deck → ${actor} hand; retain original starting hands.`);
|
||||
return card;
|
||||
}
|
||||
|
||||
/** Original pair, printed blow and physical token lottery, actual paid typed
|
||||
* collection, inspection/reveal and END Mentat. Stops BEFORE turn-two Storm
|
||||
* at the authenticated collector's real free-entry control. */
|
||||
export function createPairedDiscoveryNexusFixture(options: PairedDiscoveryNexusOptions = {}): PairedDiscoveryNexusFixture {
|
||||
let game = initializePairedDiscoveryNexusForFixture(options);
|
||||
const initial = structuredClone(game), setup = structuredClone(game);
|
||||
const family = game.expansions[0] as 'ix' | 'choam';
|
||||
const actor = (faction: string) => game.players.find(p => p.faction === faction)!.id;
|
||||
const owner = actor(family === 'ix' ? 'ixians' : 'richese');
|
||||
const native = actor(family === 'ix' ? 'tleilaxu' : 'choam'), guild = actor('guild'), fremen = actor('fremen');
|
||||
const collector = family === 'ix' ? owner : guild;
|
||||
const actions: PairedDiscoveryNexusStep[] = [], staging = [
|
||||
'Conserved original unplayed Spice order: printed Hagga Basin, optional Advanced Broken Land; then Great Maker, Sihaya Ridge and optional Advanced Rock Outcroppings.',
|
||||
'Selected actual Shrine supply-token and original scalar Storm/shuffle lotteries only; all UUIDs, actors, setup offers and starting hands remain original.',
|
||||
];
|
||||
game = options.initial ? advancePairedDiscoveryNexus(game, g => g.status === 'playing', actions)
|
||||
: withClassicDiscoveryNexusLottery(() => advancePairedDiscoveryNexus(game, g => g.status === 'playing', actions));
|
||||
const afterSetup = structuredClone(game);
|
||||
orderSpice(game);
|
||||
game = advancePairedDiscoveryNexus(game, g => g.phase === 1 && pairedDiscoveryNexusClean(g), actions);
|
||||
for (let n = 0; !game.discoveries!.tokens.some(t => t.face === 'shrine' && t.status === 'placed') && n < 100; n++) {
|
||||
const step = nextPairedDiscoveryNexusStep(game); assert.ok(step); actions.push(structuredClone(step));
|
||||
game = withClassicDiscoveryNexusToken(game, 'shrine', () => applyAction(game, step.actor, step.action));
|
||||
}
|
||||
const token = game.discoveries!.tokens.find(t => t.face === 'shrine')!; assert.equal(token.status, 'placed');
|
||||
const printed = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'];
|
||||
const source = `${printed.territory}:${printed.sector}`;
|
||||
game = advancePairedDiscoveryNexus(game, g => g.phase === 5 && g.active === collector && pairedDiscoveryNexusClean(g), actions);
|
||||
game = applyPairedDiscoveryNexusStep(game, { actor: collector, action: { type: 'ship', territory: printed.territory,
|
||||
sector: printed.sector, amount: 3, elite: family === 'ix' ? 1 : 0, allyPayment: 0 } }, actions);
|
||||
game = advancePairedDiscoveryNexus(game, g => g.phase === 7 && pairedDiscoveryNexusClean(g), actions);
|
||||
const collection = structuredClone(game);
|
||||
assert.ok(viewGame(game, collector).discoveries!.canInspect.includes(token.id));
|
||||
game = applyPairedDiscoveryNexusStep(game, { actor: collector, action: { type: 'discovery', token: token.id, reveal: false } }, actions);
|
||||
game = applyPairedDiscoveryNexusStep(game, { actor: collector, action: { type: 'discovery', token: token.id, reveal: true } }, actions);
|
||||
game = advancePairedDiscoveryNexus(game, g => g.phase === 8 && pairedDiscoveryNexusClean(g), actions);
|
||||
reservePairedDiscoveryNexusBoard(game, [source, MOBILE_LOCATION]);
|
||||
const claim = family === 'ix' ? 'arrakeen' : 'carthag';
|
||||
placePairedDiscoveryNexus(game, owner, `${claim}:${territory(claim).sectors[0]}`, 1);
|
||||
placePairedDiscoveryNexus(game, native, 'hagga_basin:12', 2);
|
||||
staging.push(`Before actual END Mentat: conserve original unrelated board groups to reserves, keep paid parent/HMS counters, place one ${owner} ${claim} claim counter and two ${native} Hagga Basin worm-source counters. No custody, clocks, paid history or wallets assigned.`);
|
||||
let firstMentat: PairedDiscoveryNexusTransition | undefined;
|
||||
for (let n = 0; game.turn === 1 && n < 100; n++) {
|
||||
const before = structuredClone(game), step = nextPairedDiscoveryNexusStep(game); assert.ok(step);
|
||||
game = applyPairedDiscoveryNexusStep(game, step, actions);
|
||||
if (game.turn === 2) firstMentat = { before, step, after: structuredClone(game) };
|
||||
}
|
||||
assert.ok(firstMentat);
|
||||
game = advancePairedDiscoveryNexus(game, g => g.decision?.kind === 'discoveryEntry', actions);
|
||||
assert.equal(game.phase, 0); assert.equal(game.turn, 2); assert.equal(game.decision!.player, collector);
|
||||
return { initial, setup, afterSetup, collection, firstMentat, game, family, owner, native, collector,
|
||||
guild, fremen, token: token.id, source, claim, actions, staging };
|
||||
}
|
||||
|
||||
/** Native human entry, all storm-order votes, exact Fremen typed reserve ride,
|
||||
* both Spice piles, reciprocal classic alliance and actual closing singletons. */
|
||||
export function closePairedDiscoveryNexus(f: PairedDiscoveryNexusFixture): PairedDiscoveryNexusClosing {
|
||||
const before = structuredClone(f.game), offer = viewGame(before, f.collector).discoveryEntry!;
|
||||
const step: PairedDiscoveryNexusStep = { actor: f.collector, action: { type: 'decision', event: offer.event,
|
||||
accept: true, groups: [{ source: f.source, normal: 1, elite: f.family === 'ix' ? 1 : 0 }] } };
|
||||
let game = applyPairedDiscoveryNexusStep(before, step, f.actions);
|
||||
const entry = { before, step, after: structuredClone(game) };
|
||||
game = advancePairedDiscoveryNexus(game, g => g.greatMaker?.stage === 'vote', f.actions);
|
||||
const vote = structuredClone(game);
|
||||
while (game.decision?.kind === 'greatMakerVote') game = applyPairedDiscoveryNexusStep(game,
|
||||
{ actor: game.decision.player, action: { type: 'decision', event: game.decision.event, yes: true } }, f.actions);
|
||||
assert.equal(game.decision?.kind, 'greatMakerRide'); assert.equal(game.decision!.player, f.fremen);
|
||||
const rideBefore = structuredClone(game), action = greatMakerRideAction(viewGame(game, f.fremen), 'polar_sink', 0, 2, game.advanced ? 1 : 0);
|
||||
assert.ok(action);
|
||||
const rideStep = { actor: f.fremen, action }; game = applyPairedDiscoveryNexusStep(game, rideStep, f.actions);
|
||||
const ride = { before: rideBefore, step: rideStep, after: structuredClone(game) };
|
||||
game = advancePairedDiscoveryNexus(game, g => !!g.nexus && !g.spiceWindow && !g.spiceResolution && pairedDiscoveryNexusClean(g), f.actions);
|
||||
game = applyPairedDiscoveryNexusStep(game, { actor: f.guild, action: { type: 'alliance', target: f.fremen } }, f.actions);
|
||||
game = applyPairedDiscoveryNexusStep(game, { actor: f.fremen, action: { type: 'alliance', target: f.guild } }, f.actions);
|
||||
const alliance = structuredClone(game);
|
||||
game = advancePairedDiscoveryNexus(game, g => g.nexusCards?.phase?.stage === 'drawing', f.actions);
|
||||
const cards = game.nexusCards!.cards!;
|
||||
const faces = f.family === 'ix' ? ['ixians', 'tleilaxu'] as const : ['richese', 'choam'] as const;
|
||||
for (const face of faces) assert.ok(cards.deck.includes(face));
|
||||
cards.deck = [...faces, ...cards.deck.filter(c => !faces.some(face => face === c))];
|
||||
f.staging.push('Conserve undealt all12 singleton order only; actual unallied closing draw actions deal the two native Cunning cards AFTER both Advanced piles and reciprocal alliance.');
|
||||
const drawing = structuredClone(game);
|
||||
for (const actor of [f.owner, f.native]) game = applyPairedDiscoveryNexusStep(game,
|
||||
{ actor, action: { type: 'nexusCardChoice', turn: game.turn, card: null, choice: 'draw', ownRedraws: 0 } }, f.actions);
|
||||
game = advancePairedDiscoveryNexus(game, g => g.nexusCards?.phase?.stage !== 'drawing', f.actions);
|
||||
return { entry, vote, ride, alliance, drawing, afterDraw: structuredClone(game) };
|
||||
}
|
||||
export function pairedDiscoveryRicheseRequest(f: PairedDiscoveryNexusFixture, game: Game): Action {
|
||||
assert.equal(f.family, 'choam'); assert.equal(validGameLocation(game, 'shrine', 0), true);
|
||||
const p = pairedDiscoveryNexusPlayer(game, f.owner), offer = viewGame(game, f.owner).nexusRicheseCunning!;
|
||||
assert.ok(offer && !offer.blocked);
|
||||
return { type: 'ship', territory: 'shrine', sector: 0, noField: p.noField!.tokens.find(t => t.value === 3)!.id,
|
||||
revealedToken: p.noField!.tokens.find(t => t.value === 5)!.id,
|
||||
event: p.noFieldEvent, nexus: offer.event, allyPayment: 0 };
|
||||
}
|
||||
export function settlePairedDiscoveryNexusShipment(game: Game): Game {
|
||||
return advancePairedDiscoveryNexus(game, g => !g.pendingShipment && pairedDiscoveryNexusClean(g));
|
||||
}
|
||||
|
||||
/** Conserved original private identities only, never revealed-stock/paid history.
|
||||
* Select the genuine Face Dancer from its existing stock or exchange with the
|
||||
* exact original unplayed Traitor/Face Dancer custody, before the native battle. */
|
||||
function matchDancer(game: Game, actor: string, leader: string, staging: string[]): void {
|
||||
const stock = pairedDiscoveryNexusPlayer(game, actor).faceDancers!;
|
||||
if (stock.some(c => c.leader === leader && !c.revealed)) return;
|
||||
const dancer = stock.find(c => !c.revealed); assert.ok(dancer);
|
||||
const old = dancer.leader, index = game.traitorReserve!.indexOf(leader);
|
||||
if (index >= 0) game.traitorReserve![index] = old;
|
||||
else {
|
||||
const holder = game.players.find(p => p.traitors.includes(leader)); assert.ok(holder);
|
||||
holder.traitors[holder.traitors.indexOf(leader)] = old;
|
||||
}
|
||||
dancer.leader = leader;
|
||||
staging.push(`Conserved original unplayed private identities: ${leader} ↔ ${old}; no revealed flag or replacement history assigned.`);
|
||||
}
|
||||
/** Native nested battle after actual entry and closing deal. E2 begins only
|
||||
* AFTER real paid two-marker materialization/reveal, never mixed marker plans. */
|
||||
export function preparePairedDiscoveryNexusBattle(f: PairedDiscoveryNexusFixture, state: Game): PairedDiscoveryNexusBattle {
|
||||
let game = advancePairedDiscoveryNexus(state, g => g.phase === 5 && pairedDiscoveryNexusClean(g));
|
||||
reservePairedDiscoveryNexusBoard(game, f.family === 'ix' ? [MOBILE_LOCATION] : ['shrine:0']);
|
||||
const loser = f.family === 'ix' ? f.native : f.guild;
|
||||
if (f.family === 'ix') {
|
||||
placePairedDiscoveryNexus(game, f.owner, 'shrine:0', 6, 3);
|
||||
placePairedDiscoveryNexus(game, f.native, f.source, 3);
|
||||
} else {
|
||||
assert.equal(pairedDiscoveryNexusPlayer(game, f.owner).forces['shrine:0'], 8);
|
||||
assert.equal(pairedDiscoveryNexusPlayer(game, f.owner).noField!.deployed, null);
|
||||
// The collector's earned entry counter is conserved back to its own reserve;
|
||||
// only Richese's actually paid/materialized eight-counter group stays here.
|
||||
const collector = pairedDiscoveryNexusPlayer(game, f.collector);
|
||||
collector.reserves += collector.forces['shrine:0'] ?? 0;
|
||||
delete collector.forces['shrine:0'];
|
||||
}
|
||||
placePairedDiscoveryNexus(game, loser, 'shrine:0', 6);
|
||||
const other = gameTerritories(game).find(t => t.type === 'sand' && t.id !== 'hagga_basin' && t.id !== 'gara_kulon' &&
|
||||
t.sectors.some(s => s >= 9 && s !== game.storm)); assert.ok(other);
|
||||
const key = `${other.id}:${other.sectors.find(s => s >= 9 && s !== game.storm)!}`;
|
||||
for (const actor of [f.owner, loser]) placePairedDiscoveryNexus(game, actor, key, 1);
|
||||
const leader = pairedDiscoveryNexusPlayer(game, f.owner).leaders.filter(l => !l.dead && !isAuditorLeader(l)).sort((a, b) => b.strength - a.strength)[0];
|
||||
const enemy = pairedDiscoveryNexusPlayer(game, loser).leaders.filter(l => !l.dead && !isAuditorLeader(l)).sort((a, b) => a.strength - b.strength)[0];
|
||||
assert.ok(leader && enemy);
|
||||
if (f.family === 'ix') matchDancer(game, f.native, leader.id, f.staging);
|
||||
const weapon = holdPairedDiscoveryNexusCard(game, f.owner, c => c.kind === 'projectile', f.staging).id;
|
||||
const defense = holdPairedDiscoveryNexusCard(game, f.owner, c => c.kind === 'shield', f.staging).id;
|
||||
f.staging.push('Before actual Movement completion: conserve exact original subtype reserve/board groups into the REVEALED Shrine, parent replacement source and second native conflict; retain original hands, earned held cards, wallets and real Nexus deals.');
|
||||
game = advancePairedDiscoveryNexus(game, g => g.phase === 6 && pairedDiscoveryNexusClean(g));
|
||||
const beforeBattle = structuredClone(game), actor = game.active!; assert.ok(actor === f.owner || actor === loser);
|
||||
const battleStep: PairedDiscoveryNexusStep = { actor, action: { type: 'chooseBattle', territory: 'shrine', target: actor === f.owner ? loser : f.owner } };
|
||||
game = applyPairedDiscoveryNexusStep(game, battleStep);
|
||||
game = advancePairedDiscoveryNexus(game, g => !!g.battle && !g.battle.revealed && !nextPairedDiscoveryNexusStep(g));
|
||||
const plans: PairedDiscoveryNexusStep[] = [
|
||||
{ actor: f.owner, action: { type: 'battlePlan', leader: leader.id, dial: f.family === 'ix' ? 2 : 3,
|
||||
support: f.family === 'choam' && game.advanced ? 3 : 0, weapon, defense } },
|
||||
{ actor: loser, action: { type: 'battlePlan', leader: enemy.id, dial: 0, support: 0, weapon: null, defense: null } },
|
||||
];
|
||||
return { game, beforeBattle, battleStep, winner: f.owner, loser, leader: leader.id, enemy: enemy.id, weapon, defense, source: f.source, plans };
|
||||
}
|
||||
export function revealPairedDiscoveryNexusBattle(b: PairedDiscoveryNexusBattle, state = b.game): Game {
|
||||
let game = structuredClone(state);
|
||||
for (const step of b.plans) {
|
||||
game = applyPairedDiscoveryNexusStep(game, step);
|
||||
game = advancePairedDiscoveryNexus(game, g => !g.response && g.decision?.kind !== 'fullPlanOffer');
|
||||
}
|
||||
assert.ok(game.battle?.revealed); return game;
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import assert from 'node:assert/strict';
|
||||
import {
|
||||
RULE_TOPICS,
|
||||
RULE_CHECKLIST_AREAS,
|
||||
PHASE_HELP,
|
||||
phaseRuleId,
|
||||
} from '../game/reference';
|
||||
import { FACTIONS, PHASES } from '../game/catalog';
|
||||
@@ -13,11 +12,9 @@ void test('every phase and base faction has a discoverable rule topic with valid
|
||||
const ids = new Set(RULE_TOPICS.map((t) => t.id));
|
||||
assert.equal(ids.size, RULE_TOPICS.length);
|
||||
for (const topic of RULE_TOPICS) {
|
||||
assert.ok(topic.steps.length > 0, topic.id);
|
||||
for (const related of topic.related ?? [])
|
||||
assert.ok(ids.has(related), `${topic.id} -> ${related}`);
|
||||
}
|
||||
assert.equal(PHASE_HELP.length, PHASES.length);
|
||||
PHASES.forEach((_, i) => assert.ok(ids.has(phaseRuleId(i))));
|
||||
FACTIONS.forEach((f) => assert.ok(ids.has(`faction-${f.id}`)));
|
||||
assert.ok(ids.has('ai-players'));
|
||||
|
||||
+41
-10
@@ -52,7 +52,9 @@ type Profile = 'base' | 'choam' | 'ecaz' | 'ecaz-treachery' | 'ecaz-occupy' | 'm
|
||||
| 'discovery' | 'discovery-tech' | 'discovery-skills' | 'discovery-skills-tech'
|
||||
| 'discovery-stronghold' | 'discovery-stronghold-tech' | 'discovery-skills-stronghold' | 'discovery-skills-stronghold-tech'
|
||||
| 'native-discovery-skills' | 'native-discovery-skills-tech' | 'native-discovery-skills-stronghold' | 'native-discovery-skills-stronghold-tech'
|
||||
| 'discovery-nexus' | 'discovery-nexus-tech' | 'discovery-nexus-stronghold' | 'discovery-nexus-stronghold-tech';
|
||||
| 'discovery-nexus' | 'discovery-nexus-tech' | 'discovery-nexus-stronghold' | 'discovery-nexus-stronghold-tech'
|
||||
| 'discovery-nexus-skills' | 'discovery-nexus-skills-tech' | 'discovery-nexus-skills-stronghold' | 'discovery-nexus-skills-stronghold-tech'
|
||||
| 'paired-discovery-nexus' | 'paired-discovery-nexus-tech' | 'paired-discovery-nexus-stronghold' | 'paired-discovery-nexus-stronghold-tech';
|
||||
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' ||
|
||||
@@ -64,6 +66,12 @@ const discoveryTechProfile = (profile: string) => profile === 'discovery-tech' |
|
||||
const nativeDiscoverySkillsProfile = (profile: string) => profile === 'native-discovery-skills' ||
|
||||
profile === 'native-discovery-skills-tech' || profile === 'native-discovery-skills-stronghold' ||
|
||||
profile === 'native-discovery-skills-stronghold-tech';
|
||||
const discoveryNexusSkillsProfile = (profile: string) => profile === 'discovery-nexus-skills' ||
|
||||
profile === 'discovery-nexus-skills-tech' || profile === 'discovery-nexus-skills-stronghold' ||
|
||||
profile === 'discovery-nexus-skills-stronghold-tech';
|
||||
const pairedDiscoveryNexusSampleProfile = (profile: string) => profile === 'paired-discovery-nexus' ||
|
||||
profile === 'paired-discovery-nexus-tech' || profile === 'paired-discovery-nexus-stronghold' ||
|
||||
profile === 'paired-discovery-nexus-stronghold-tech';
|
||||
type Rules = 'basic' | 'advanced';
|
||||
|
||||
type Scenario = {
|
||||
@@ -191,22 +199,28 @@ const CLASSIC_NEXUS_MODULE_SCENARIOS: readonly Scenario[] =
|
||||
.map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 800 + band * 100 + index })));
|
||||
const discoveryNexusProfile = (profile: string) => profile === 'discovery-nexus' ||
|
||||
profile === 'discovery-nexus-tech' || profile === 'discovery-nexus-stronghold' ||
|
||||
profile === 'discovery-nexus-stronghold-tech';
|
||||
profile === 'discovery-nexus-stronghold-tech' || discoveryNexusSkillsProfile(profile) ||
|
||||
pairedDiscoveryNexusSampleProfile(profile);
|
||||
const nexusModuleProfile = (profile: string) =>
|
||||
profile === 'nexus-tech' || profile === 'nexus-stronghold' || profile === 'nexus-stronghold-tech' ||
|
||||
discoveryNexusProfile(profile) || pairedNexusModuleProfile(profile) || nexusSkillsModuleProfile(profile) || pairedNexusSkillsModuleProfile(profile) || e3NexusSkillsProfile(profile);
|
||||
const nexusTechProfile = (profile: string) => profile === 'discovery-nexus-tech' || profile === 'discovery-nexus-stronghold-tech' || profile === 'nexus-tech' || profile === 'nexus-stronghold-tech' ||
|
||||
const nexusTechProfile = (profile: string) =>
|
||||
((discoveryNexusSkillsProfile(profile) || pairedDiscoveryNexusSampleProfile(profile)) && profile.endsWith('tech')) ||
|
||||
profile === 'discovery-nexus-tech' || profile === 'discovery-nexus-stronghold-tech' || profile === 'nexus-tech' || profile === 'nexus-stronghold-tech' ||
|
||||
profile === 'paired-nexus-tech' || profile === 'paired-nexus-stronghold-tech' ||
|
||||
profile === 'nexus-skills-tech' || profile === 'nexus-skills-stronghold-tech' ||
|
||||
profile === 'paired-nexus-skills-tech' || profile === 'paired-nexus-skills-stronghold-tech' ||
|
||||
profile === 'e3-nexus-skills-tech' || profile === 'e3-nexus-skills-stronghold-tech';
|
||||
const nexusStrongholdProfile = (profile: string) => profile === 'discovery-nexus-stronghold' || profile === 'discovery-nexus-stronghold-tech' || profile === 'nexus-stronghold' || profile === 'nexus-stronghold-tech' ||
|
||||
const nexusStrongholdProfile = (profile: string) =>
|
||||
((discoveryNexusSkillsProfile(profile) || pairedDiscoveryNexusSampleProfile(profile)) && profile.includes('stronghold')) ||
|
||||
profile === 'discovery-nexus-stronghold' || profile === 'discovery-nexus-stronghold-tech' || profile === 'nexus-stronghold' || profile === 'nexus-stronghold-tech' ||
|
||||
profile === 'paired-nexus-stronghold' || profile === 'paired-nexus-stronghold-tech' ||
|
||||
profile === 'nexus-skills-stronghold' || profile === 'nexus-skills-stronghold-tech' ||
|
||||
profile === 'paired-nexus-skills-stronghold' || profile === 'paired-nexus-skills-stronghold-tech' ||
|
||||
profile === 'e3-nexus-skills-stronghold' || profile === 'e3-nexus-skills-stronghold-tech';
|
||||
const pairedNexusModuleProfile = (profile: string) => profile === 'paired-nexus-tech' ||
|
||||
profile === 'paired-nexus-stronghold' || profile === 'paired-nexus-stronghold-tech';
|
||||
profile === 'paired-nexus-stronghold' || profile === 'paired-nexus-stronghold-tech' ||
|
||||
pairedDiscoveryNexusSampleProfile(profile);
|
||||
const EXPANSION_ROSTER_SCENARIOS: readonly Scenario[] = [
|
||||
{ profile: 'choam-roster' as const, expansions: ['choam'], roster: ['choam', 'richese', 'emperor', 'guild', 'harkonnen', 'fremen'] as FactionId[] },
|
||||
{ profile: 'ecaz-roster' as const, expansions: ['ecaz'], roster: ['moritani', 'ecaz', 'atreides', 'beneGesserit', 'harkonnen', 'guild'] as FactionId[] },
|
||||
@@ -436,6 +450,19 @@ const NATIVE_DISCOVERY_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: 4400 + band * 100 + index })));
|
||||
const DISCOVERY_NEXUS_SKILLS_SCENARIOS: readonly Scenario[] =
|
||||
(['discovery-nexus-skills', 'discovery-nexus-skills-tech', 'discovery-nexus-skills-stronghold', 'discovery-nexus-skills-stronghold-tech'] as const)
|
||||
.flatMap((profile, band) => BASE_SCENARIOS
|
||||
.filter(scenario => (!profile.includes('stronghold') || scenario.rules === 'advanced') &&
|
||||
(!profile.endsWith('tech') || scenario.roster.length >= 3))
|
||||
.map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 4800 + band * 100 + index })));
|
||||
const PAIRED_DISCOVERY_NEXUS_SCENARIOS: readonly Scenario[] =
|
||||
(['paired-discovery-nexus', 'paired-discovery-nexus-tech', 'paired-discovery-nexus-stronghold', 'paired-discovery-nexus-stronghold-tech'] as const)
|
||||
.flatMap((profile, band) => PAIRED_NEXUS_SCENARIOS
|
||||
.filter(scenario => scenario.profile !== 'ecaz-nexus' &&
|
||||
(!profile.includes('stronghold') || scenario.rules === 'advanced') &&
|
||||
(!profile.endsWith('tech') || scenario.roster.length >= 3))
|
||||
.map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 5200 + band * 100 + index })));
|
||||
const STRONGHOLD_FACTIONS_ROSTER: readonly FactionId[] = [
|
||||
'ixians', 'choam', 'emperor', 'fremen', 'harkonnen', 'beneGesserit',
|
||||
];
|
||||
@@ -514,7 +541,7 @@ const SKILLS_STRONGHOLD_SCENARIOS: readonly Scenario[] =
|
||||
const strongholdSkillProfile = (profile: string) => profile === 'skills-stronghold' || profile === 'skills-stronghold-tech';
|
||||
const discoverySkillsProfile = (profile: string) => profile === 'discovery-skills' || profile === 'discovery-skills-tech' ||
|
||||
profile === 'discovery-skills-stronghold' || profile === 'discovery-skills-stronghold-tech';
|
||||
const skillProfile = (profile: string) => nativeDiscoverySkillsProfile(profile) || discoverySkillsProfile(profile) || e3NexusSkillsProfile(profile) || pairedNexusSkillsModuleProfile(profile) || nexusSkillsModuleProfile(profile) || profile === 'nexus-skills' || profile === 'paired-nexus-skills' || profile === 'moritani-skills' || profile === 'tleilaxu-skills' || profile === 'ix-skills' || profile === 'choam-skills' || profile === 'richese-skills' || profile === 'ecaz-skills' || profile === 'skills-tech' || strongholdSkillProfile(profile);
|
||||
const skillProfile = (profile: string) => discoveryNexusSkillsProfile(profile) || nativeDiscoverySkillsProfile(profile) || discoverySkillsProfile(profile) || e3NexusSkillsProfile(profile) || pairedNexusSkillsModuleProfile(profile) || nexusSkillsModuleProfile(profile) || profile === 'nexus-skills' || profile === 'paired-nexus-skills' || profile === 'moritani-skills' || profile === 'tleilaxu-skills' || profile === 'ix-skills' || profile === 'choam-skills' || profile === 'richese-skills' || profile === 'ecaz-skills' || profile === 'skills-tech' || strongholdSkillProfile(profile);
|
||||
const discoveryProfile = (profile: string) => profile === 'discovery' || profile === 'discovery-tech' ||
|
||||
profile === 'discovery-stronghold' || profile === 'discovery-stronghold-tech';
|
||||
const assassinationScenario = (scenario: Scenario) => scenario.rules === 'advanced' &&
|
||||
@@ -560,6 +587,7 @@ function usage() {
|
||||
'[--seed UINT32] [--profile all|base|discovery|discovery-tech|discovery-skills|discovery-skills-tech|discovery-nexus|discovery-nexus-tech|discovery-nexus-stronghold|discovery-nexus-stronghold-tech|choam|ecaz|ecaz-treachery|ecaz-occupy|moritani-assassinate|stronghold-factions|factions-tech|stronghold-factions-tech|combined|combined-nexus|combined-homeworld-nexus|homeworld|homeworld-occupation|nexus|nexus-skills|paired-nexus-skills|nexus-skills-tech|nexus-skills-stronghold|nexus-skills-stronghold-tech|paired-nexus-skills-tech|paired-nexus-skills-stronghold|paired-nexus-skills-stronghold-tech|e3-nexus-skills|e3-nexus-skills-tech|e3-nexus-skills-stronghold|e3-nexus-skills-stronghold-tech|nexus-tech|nexus-stronghold|nexus-stronghold-tech|paired-nexus-tech|paired-nexus-stronghold|paired-nexus-stronghold-tech|homeworld-nexus|choam-roster|ecaz-roster|ix-roster|choam-nexus|ecaz-nexus|ix-nexus|moritani-skills|tleilaxu-skills|ix-skills|choam-skills|richese-skills|ecaz-skills|skills-tech|skills-stronghold|skills-stronghold-tech] ' +
|
||||
'[--rules both|basic|advanced] [--players all|2|3|4|5|6] [--max-actions POSITIVE] ' +
|
||||
'[--resume FAILED_GAME.json]\n' +
|
||||
'Discovery Nexus combinations: discovery-nexus-skills[-tech|-stronghold|-stronghold-tech] uses original classic all14 training; paired-discovery-nexus[-tech|-stronghold|-stronghold-tech] uses both native E1 or E2 factions without Skills. Basic/Advanced2–6, Tech3+, Strongholds Advanced, no Homeworlds/variants/previews; old profiles/defaults/seeds unchanged.\n' +
|
||||
'Native skill Discovery profiles: native-discovery-skills, native-discovery-skills-tech, native-discovery-skills-stronghold, native-discovery-skills-stronghold-tech; original native skill families/rosters and setup, optional Tech3+ and Advanced Strongholds, no Nexus/Homeworlds/variants/previews. Earlier profiles/defaults/seeds remain unchanged.\n' +
|
||||
'Advanced Discovery Stronghold profiles: discovery-stronghold, discovery-stronghold-tech, discovery-skills-stronghold, discovery-skills-stronghold-tech; native or classic-skill original setup, optional Tech3+, original held claims, no Nexus/Homeworlds/variants/previews. Earlier defaults and seeds are unchanged.\n' +
|
||||
'Runs genuine setup and gameplay offline. Default/all keeps the six expansion samples; combined-nexus and combined-homeworld-nexus add five/six-seat Basic/Advanced all-expansion samples. Base, Homeworld, Nexus, Homeworld-Nexus, expansion roster, paired expansion Nexus, Ecaz card variant, Advanced Moritani assassination and skill profiles select their documented rosters. Stronghold-factions is Advanced only: Ixians + CHOAM, native Ixians + Tleilaxu, or native CHOAM + Richese with classic opponents, two through six seats; mixed four-native rosters also run at four through six seats. Selected Ix/CHOAM decks determine the canonical 47/35 Treachery Cards; Richese has a separate ten-card cache and all samples have six separate Stronghold Cards without other modules. Scenario names identify their native roster. --players requires a supported profile. Output must be a new private directory outside the checkout.' +
|
||||
@@ -612,7 +640,8 @@ function positive(value: string | undefined) {
|
||||
}
|
||||
|
||||
function scenarioName(scenario: Scenario) {
|
||||
if (nativeDiscoverySkillsProfile(scenario.profile))
|
||||
if (nativeDiscoverySkillsProfile(scenario.profile) || discoveryNexusSkillsProfile(scenario.profile) ||
|
||||
pairedDiscoveryNexusSampleProfile(scenario.profile))
|
||||
return `${scenario.profile}-${scenario.expansions.join('-')}-${scenario.roster.join('-')}-${scenario.roster.length}-${scenario.rules}`;
|
||||
if (discoveryProfile(scenario.profile))
|
||||
return `${scenario.profile}-${scenario.expansions[0] ?? 'classic'}-${scenario.roster[0]}-${scenario.roster.length}-${scenario.rules}`;
|
||||
@@ -779,7 +808,7 @@ function resumedGame(path: string) {
|
||||
game.mentatQuestionPreview
|
||||
)
|
||||
throw new Error('--resume sample scenarios exclude unsupported optional modules.');
|
||||
const scenario = [...SCENARIOS, ...COMBINED_NEXUS_SCENARIOS, ...COMBINED_HOMEWORLD_NEXUS_SCENARIOS, ...BASE_SCENARIOS, ...MODULE_SCENARIOS, ...EXPANSION_ROSTER_SCENARIOS, ...ECAZ_TREACHERY_SCENARIOS, ...ECAZ_OCCUPY_SCENARIOS, ...PAIRED_NEXUS_SCENARIOS, ...MORITANI_ASSASSINATE_SCENARIOS, ...STRONGHOLD_FACTIONS_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...IX_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS, ...RICHESE_SKILLS_SCENARIOS, ...ECAZ_SKILLS_SCENARIOS, ...SKILLS_TECH_SCENARIOS, ...SKILLS_STRONGHOLD_SCENARIOS, ...NATIVE_TECH_SCENARIOS, ...CLASSIC_NEXUS_MODULE_SCENARIOS, ...PAIRED_NEXUS_MODULE_SCENARIOS, ...NEXUS_SKILLS_SCENARIOS, ...PAIRED_NEXUS_SKILLS_SCENARIOS, ...NEXUS_SKILLS_MODULE_SCENARIOS, ...PAIRED_NEXUS_SKILLS_MODULE_SCENARIOS, ...E3_NEXUS_SKILLS_SCENARIOS, ...HOMEWORLD_OCCUPATION_SCENARIOS, ...DISCOVERY_SCENARIOS, ...DISCOVERY_SKILLS_SCENARIOS, ...DISCOVERY_NEXUS_SCENARIOS, ...DISCOVERY_STRONGHOLD_SCENARIOS, ...NATIVE_DISCOVERY_SKILLS_SCENARIOS].find(
|
||||
const scenario = [...SCENARIOS, ...COMBINED_NEXUS_SCENARIOS, ...COMBINED_HOMEWORLD_NEXUS_SCENARIOS, ...BASE_SCENARIOS, ...MODULE_SCENARIOS, ...EXPANSION_ROSTER_SCENARIOS, ...ECAZ_TREACHERY_SCENARIOS, ...ECAZ_OCCUPY_SCENARIOS, ...PAIRED_NEXUS_SCENARIOS, ...MORITANI_ASSASSINATE_SCENARIOS, ...STRONGHOLD_FACTIONS_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...IX_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS, ...RICHESE_SKILLS_SCENARIOS, ...ECAZ_SKILLS_SCENARIOS, ...SKILLS_TECH_SCENARIOS, ...SKILLS_STRONGHOLD_SCENARIOS, ...NATIVE_TECH_SCENARIOS, ...CLASSIC_NEXUS_MODULE_SCENARIOS, ...PAIRED_NEXUS_MODULE_SCENARIOS, ...NEXUS_SKILLS_SCENARIOS, ...PAIRED_NEXUS_SKILLS_SCENARIOS, ...NEXUS_SKILLS_MODULE_SCENARIOS, ...PAIRED_NEXUS_SKILLS_MODULE_SCENARIOS, ...E3_NEXUS_SKILLS_SCENARIOS, ...HOMEWORLD_OCCUPATION_SCENARIOS, ...DISCOVERY_SCENARIOS, ...DISCOVERY_SKILLS_SCENARIOS, ...DISCOVERY_NEXUS_SCENARIOS, ...DISCOVERY_STRONGHOLD_SCENARIOS, ...NATIVE_DISCOVERY_SKILLS_SCENARIOS, ...DISCOVERY_NEXUS_SKILLS_SCENARIOS, ...PAIRED_DISCOVERY_NEXUS_SCENARIOS].find(
|
||||
(candidate) =>
|
||||
(nativeDiscoverySkillsProfile(candidate.profile) || discoveryProfile(candidate.profile) || discoverySkillsProfile(candidate.profile) || discoveryNexusProfile(candidate.profile)) === discovery &&
|
||||
(candidate.profile === 'homeworld' || candidate.profile === 'homeworld-occupation' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.homeworlds &&
|
||||
@@ -1007,10 +1036,12 @@ async function main() {
|
||||
throw new Error('Stronghold samples require Advanced rules.');
|
||||
if ((profile === 'skills-tech' || profile === 'skills-stronghold-tech') && players === 2)
|
||||
throw new Error('Leader Skills with Tech Tokens samples require three through six players.');
|
||||
if ((discoveryTechProfile(profile) || profile === 'discovery-nexus-tech' || profile === 'discovery-nexus-stronghold-tech') && players === 2)
|
||||
if ((discoveryTechProfile(profile) || (discoveryNexusProfile(profile) && nexusTechProfile(profile))) && players === 2)
|
||||
throw new Error('Discovery with Tech Tokens requires three through six players.');
|
||||
const resume = values.resume ? resumedGame(values.resume) : null;
|
||||
const requestedSamples = nativeDiscoverySkillsProfile(profile) ? NATIVE_DISCOVERY_SKILLS_SCENARIOS
|
||||
const requestedSamples = discoveryNexusSkillsProfile(profile) ? DISCOVERY_NEXUS_SKILLS_SCENARIOS
|
||||
: pairedDiscoveryNexusSampleProfile(profile) ? PAIRED_DISCOVERY_NEXUS_SCENARIOS
|
||||
: nativeDiscoverySkillsProfile(profile) ? NATIVE_DISCOVERY_SKILLS_SCENARIOS
|
||||
: discoveryStrongholdProfile(profile) ? DISCOVERY_STRONGHOLD_SCENARIOS
|
||||
: discoveryNexusProfile(profile) ? DISCOVERY_NEXUS_SCENARIOS
|
||||
: discoverySkillsProfile(profile) ? DISCOVERY_SKILLS_SCENARIOS
|
||||
|
||||
@@ -42,8 +42,8 @@ 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 unused original Tech3–6 and Advanced Strongholds2–6. Original decks/cache, HMS/Face Dancers, typed entry, revealed-only nested No-Fields, stash limits, end-Mentat held claims and native outcomes remain. Advanced Moritani excludes Harkonnen. Skills/HW/Nexus, paired/mixed E3, contested/shared-payer rulings, public starts and played-game conversion stay separate.',
|
||||
'\nLeader-skills --discoveries explicitly composes fresh supported classic or native original family decks/all14 Skills with Discovery7/8 at2–6 seats Basic/Advanced under each existing roster predicate. Unused original Tech requires3–6; original Strongholds requires Advanced2–6. Original setup, actual offered training, green native components, held claims, typed entry, nested tariffs, stash limits, rescue/native aftermath and invoices use existing controls. Standalone E3 retains Duke/assassination/Harkonnen limits. Nexus/HW/variants, Banker/Mentat and unrelated previews, pending rulings, public starts and save conversion are not opened.',
|
||||
'\nNexus --discoveries explicitly composes fresh classic/base33/all12 Nexus with original Discovery7/8 at2–6 seats Basic/Advanced. Unused Tech requires3+; optional Strongholds require Advanced; both3+. Actual Great Maker/worm/vote/reserve ride, original settled-alliance end-Spice closing card deal and end-Mentat card ownership/next entry remain. Source-clear revival/tariff/battle effects reuse original handlers. Skills/HW/native families, mixed-module Fremen Betrayal, pending rulings, public activation and save conversion stay guarded.',
|
||||
'\nLeader-skills --discoveries explicitly composes fresh supported classic or native original family decks/all14 Skills with Discovery7/8 at2–6 seats Basic/Advanced under each existing roster predicate. Unused original Tech requires3–6; original Strongholds requires Advanced2–6. Classic setups additionally accept --nexus-cards, retaining all12 physical Nexus cards, actual Great Maker votes/typed rides and the settled-alliance end-Spice closing deal. Original offers, starting hands, claims, rescue and source-clear invoices remain. Native Skills/Nexus/Discovery, HW/variants, Banker/Mentat and unrelated previews, pending rulings, public starts and save conversion stay separate.',
|
||||
'\nNexus --discoveries explicitly composes fresh classic/base33 OR both native Ixians+Tleilaxu/Ix47 OR CHOAM+Richese/CHOAM35+cache with all12 Nexus and original Discovery7/8 at2–6 seats Basic/Advanced. Unused Tech requires3+; optional Strongholds require Advanced; both3+. Actual Great Maker losses/votes/typed reserve rides, both Advanced piles, settled-alliance end-Spice closing deals and original held claims remain. Signed native two-marker nested shipments retain one printed invoice, current revealed destination and original reserve materialization. Native Skills, HW, E3/mixed families, mixed-module Fremen Betrayal, pending rulings, public activation and save conversion stay guarded.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user