Compose native Discovery skills and repair hidden trainer participation

This commit is contained in:
Quintine committed 2026-10-06 00:51:21 +11:00
1 parent d51142bf9a
commit 555afe4070
20 files changed
+2220 -27

No files matched your search

+32 -1
View File
@@ -1,6 +1,6 @@
# Current development status # Current development status
Updated 5 October 2026. **The full twelve-faction goal is unfinished. Basic Updated 6 October 2026. **The full twelve-faction goal is unfinished. Basic
is playable and the six classic factions have a user-authorized Advanced preview. is playable and the six classic factions have a user-authorized Advanced preview.
Expansion starts and full rules/publication acceptance remain gated.** Expansion starts and full rules/publication acceptance remain gated.**
@@ -17,6 +17,37 @@ open mode gates.
## Current checkpoint and work ## Current checkpoint and work
**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
Strongholds2+. Typed entry/range/rescue/Cyborg/Face Dance, native stash/
invoice/green marker and trained Moritani death/assassination/own revival use
original handlers. Native Ecaz mandatory owner-labelled coalition remains.
Other overlays/previews, pending rulings, public starts and conversion stay gated.
Affected107 pass. Actual programmes observe rescue3/three Tanks/one Cyborg
substitution, six-counter invoice5→receiver3/bank2, and five real native
leader deaths followed by turn4 Revival/printed5 debit/optional replacement.
Three genuine hidden-Smuggler stalls had legal human plans; the policy now
uses authoritative projected capability, not full-state rules on player views.
Failing-before/passing-after trainer regression covers all four policies.
All31 genuine six-seat samples complete after three unchanged capture resumes:
30,874 accepted/30,874 attempts/815 JSON across f2ec1d69 and corrected14d66863
trees. Resumes add989/387/783 no-rejection actions; they do not reconstruct the
prior random stream. Earlier duplicate-name runs remain incomplete/un-counted;
new names include full roster/expansions while old profiles/seeds stay unchanged.
Human 6NNTCFM2v12 moves one ordinary/one Cyborg into Shrine free, preserving
natural Bureaucrat and HMS6. 5NLWDL68v14 keeps native Killer Medic: actual
Stash5→6→5 through new Karama disposal without payment. MYPGMSW6 human
Master Bewt revealv10 kills its naturally trained Guild Bureaucrat, returns
that skill and pays10→13; winner cleanupv11 and ten original actions reach
v12/Mentat with retired cost and one private replacement. All390px refresh,
original starting hands/offers preserved, explicit staging and tabs released.
[Canonical native evidence](DISCOVERY_PROTOTYPE.md#native-leader-skills-composition--6-october-2026).
No full-faction, natural-every-effect, assurance or deployed acceptance claim.
**Discovery / original Strongholds — bounded Development Verified:** **Discovery / original Strongholds — bounded Development Verified:**
fresh original Discovery and separate classic Skills/Discovery preserve fresh original Discovery and separate classic Skills/Discovery preserve
Advanced Strongholds2–6, optional Tech3–6, original setup/held claims, Advanced Strongholds2–6, optional Tech3–6, original setup/held claims,
+78
View File
@@ -35,6 +35,84 @@ node --import tsx tools/start-prototype.ts --profile discovery --db PATH --room
It preserves all seats and other rooms, requires the current version and refuses It preserves all seats and other rooms, requires the current version and refuses
to redeal a started game. The default `ix` profile remains available. to redeal a started game. The default `ix` profile remains available.
## Native Leader Skills composition — 6 October 2026
Fresh explicit `leader-skills --discoveries` now preserves original native
family decks, all14 training, Discovery7/8 and each existing native Basic/
Advanced roster predicate. Original Tech needs3+; Strongholds requires Advanced.
Standalone Ecaz OR Moritani retains its Duke/assassination/Harkonnen exclusions.
No Nexus/Homeworlds/independent variants, Banker/Mentat or unrelated preview,
public start or played-save conversion is admitted.
The original native module predicates validate one Discovery envelope, not
copies of game/configuration objects. Ordinary skill quotes, Planetologist,
Suk/Rihani/Sandmaster, native invoices and Bureaucrat consume those predicates.
Original native starting hands and already offered training are authoritative;
some original E2/E3 constructors have dealt their one starting card before
training. Human programmes preserve that original unassigned setup rather
than redealing or demanding empty hands. No assigned/played setup is accepted.
Actual typed entry, paid nested routes, green components, rescue before real
Cyborg Tank substitution, cleanup/Tech before matching Face Dance, CHOAM5→6→5
Stash, native invoice5→receiver3/bank2, physical marker0/3/5 caps and mandatory
green cleanup compose existing handlers. Moritani nested assassination kills
the actual unused trained disc, returns its exact skill once, pays printed3
and receives one private actual Mentat replacement. Five original battles
kill all five native Moritani discs; original next Revival pays printed5
and opens optional replacement. No death, clock, wallet or earned receipt is
assigned to reach it.
### Native skill runtime evidence
The affected native/Smuggler union passes **107 cases**. Original rule programmes
observe three rescue survivors/three Tanks/one genuine Cyborg loss eligible
for substitution, the six-counter5-spice invoice with receiver3/bank2, and
five actual native disc deaths followed by turn4 Revival, printed5 debit,
four remaining dead discs and the private replacement decision.
Three genuine games stalled with a hidden Smuggler trainer after the opposing
plan. Actual human zero-support plans were legal, but bots incorrectly ran
full-state module predicates on partial player views. The authoritative battle
projection now supplies `smugglerCollectionSupported`; all four policies use
that quote while keeping the existing modified-strength/multiple-pile guards.
A real trainer-plan regression fails before repair and passes afterward.
The original captures resume at seeds20265473/20265497/20265501 and complete
989/387/783 more accepted actions without rejection and26/10/21 JSON.
All31 original six-seat samples now complete: nineteen Basic/Advanced native
Skills/Discovery/Tech and twelve Advanced four-module games. Combined original
and repaired continuations total30,874 accepted/30,874 attempts/815 JSON.
Initial reports bind unchanged
`f2ec1d696e1a5572abcb43a9e529b07cdf8e56dc7694f4c3c9f34cad3acd3958`;
the three corrected resumes bind unchanged
`14d6686355ebff299369e8c62f5b498e2194574b7a4eb69fc1ce5d9ff42668ea`.
This spans two trees; resume does not reconstruct the earlier stream.
Early duplicate scenario-name runs remain incomplete and are not counted:
native names now include the full original roster/selected expansion list,
without changing earlier profiles/defaults or new4400–4700 ordinal bands.
Actual **6NNTCFM2** original CLIv10 keeps natural Ixian Bureaucrat training.
Conserved original Shrine/counter positions and original first-Mentat claim
reachv11 entry. Human one-ordinary/one-Cyborg entry **v12** leaves Shrine2/
one Cyborg, one parent counter, unchanged spice/reserves and actual HMS6.
Actual **5NLWDL68** original CLIv10 keeps starting hands and natural CHOAM
Killer Medic. Explicit conserved native five-card capacity precedes Collection;
human inspectv12/revealv13 draws six, then human actual new Karama discard
**v14** restores five without price or training loss.
Actual **MYPGMSW6** original CLIv8 keeps native training and produced Cistern
after-loss opportunityv9. Human Master Bewt reveal **v10** spends the actual
Traitor, kills the Guild's naturally trained Bureaucrat, returns that skill
and pays10→13. Human winner cleanupv11 retains original Tech; ten original
actions finish the separate conflict/Collection and actual **v12/Mentat**
retires the cost and produces one private replacement. Native assassination
profile admission was repaired after its independent Discovery-first branch
rejected the real original setup. All three refresh at390px; tabs released.
Conserved positions, original token/card lotteries and original offers remain
explicit. Controlled deterministic programmes are not natural card histories;
passing samples do not prove natural use of every effect, complete factions,
full combination assurance, privacy/recovery certification or deployment.
## Classic Nexus composition — 5 October 2026 ## Classic Nexus composition — 5 October 2026
Fresh local `nexus --discoveries` composes classic/base33/all12 Nexus with the Fresh local `nexus --discoveries` composes classic/base33/all12 Nexus with the
+37
View File
@@ -1,5 +1,42 @@
# Dune implementation status # Dune implementation status
## 6 October 2026 — Original native Leader Skills and Discovery
One exact Discovery envelope feeds existing native family/roster predicates
and original all14 setup/starting hands, optional Tech3+/Advanced Strongholds2+.
No game/config copies in rule quotes; no public gate or save conversion.
Typed entry/range/rescue before Cyborg substitution, original cleanup/Tech
before Face Dance, CHOAM stash/invoice/Bureaucrat/green marker, and trained
Moritani nested death/assassination/private replacement/own revival compose
existing sources. Actual native E2/E3 printed starting hands are preserved.
Affected107 pass. Actual programmes observe rescue3/three Tanks/one real
Cyborg loss, six-counter5 fee/receiver3/bank2, and five original battle deaths
then turn4 native printed5 revival/optional replacement. Original chronology,
support-unit and separate-conflict fixture corrections remove copied math,
nonce/default assumptions rather than pinning implementation artifacts.
Three genuine hidden-Smuggler captures had legal human zero-support plans
but no bot candidate. Engine projects canonical support; bots stop recomputing
full-state module predicates on partial views. The real trainer-plan regression
fails before/passes after and keeps modified-strength/multi-pile guards.
Original seeds20265473/20265497/20265501 resume unchanged, adding989/387/783
accepted/no rejected actions and26/10/21 JSON on14d66863 tree.
All31 six-seat samples finish30,874/30,874/815 JSON including those resumes;
initial f2ec1d69 tree evidence is separate, not one frozen-tree/every-effect
claim. Earlier filename collisions are retained but un-counted; full original
rosters/expansions now distinguish new samples, without changing old names/seeds.
Actual 6NNTCFM2 original CLIv10, natural Bureaucrat/free typed Shrinev12;
5NLWDL68 original CLIv10, natural Killer Medic/Stash inspect12/reveal13/
new-card disposal14 restores5 with no payment; MYPGMSW6 original CLIv8,
real nested after-loss target9/human Master Bewt10 returns Guild Bureaucrat,
printed10→13, winner cleanup11 and original Mentat12 retires one actual cost/
draws one private replacement. Original hands/offers remain authoritative,
conserved positions explicit,390px refresh/tabs closed.
[Canonical native contract](DISCOVERY_PROTOTYPE.md#native-leader-skills-composition--6-october-2026).
Full modes, assurance, strategic AI and protected live deployment remain open.
## 5 October 2026 — Original Discovery Strongholds and mandatory Ecaz repair ## 5 October 2026 — Original Discovery Strongholds and mandatory Ecaz repair
Fresh original Discovery classic/E1/E2/standalone E3 and separate classic Fresh original Discovery classic/E1/E2/standalone E3 and separate classic
+21
View File
@@ -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) contains all fourteen physical cards; the [capture interpretation](LEADER_SKILLS_CAPTURE.md)
records the fresh publisher/designer search requested by the user. records the fresh publisher/designer search requested by the user.
## Original native Discovery composition — 6 October 2026
Fresh native `leader-skills --discoveries` composes each already-supported
Basic/Advanced native family predicate, original decks/hands/offers/all14 and
Discovery7/8, optional original Tech3+ and Advanced Strongholds2+.
E1 typed entry/range/rescue/Cyborg/Face Dance, E2 stash/invoice/green marker
and E3 trained death/assassination/own revival use original consumers.
Actual offered training and printed starting hands are not replaced.
The same supported live Ecaz profile keeps mandatory owner-labelled coalition
battles; shared Duke assignments and Basic odd-force rounding remain guarded.
Nexus/HW/variants/Banker/Mentat previews/public starts/save conversion stay
separate. This does not certify all fourteen effects or complete native factions.
[The Discovery contract](DISCOVERY_PROTOTYPE.md#native-leader-skills-composition--6-october-2026)
records affected107, actual producer programmes and three original human
390px paths. Thirty-one genuine six-seat samples complete after three actual
hidden-Smuggler policy captures are repaired/resumed:30,874 accepted/no
rejection/815 JSON across two frozen trees, not one unstaged every-effect proof.
Original Native starting-hand order, source-local held effects and physical
reward/skill-return ordering remain; comprehensive assurance stays deferred.
**2 October normal Banker checkpoint:** the separate fresh local `banker-income` **2 October normal Banker checkpoint:** the separate fresh local `banker-income`
profile connects actual final-bank payment legs, one physical Banker phase profile connects actual final-bank payment legs, one physical Banker phase
grant and automatic current Mentat collection. The full deck14 grant and automatic current Mentat collection. The full deck14
+1
View File
@@ -238,6 +238,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP
| Paired E1/E2 Nexus / Leader Skills | Fresh both Ixians+Tleilaxu OR CHOAM+Richese plus classics, one original family deck/all14 Skills/all12 Nexus, Basic/Advanced2..6, explicit original skill entry. Actual native setup/offers/closing draws remain. Full unsupported Suboids compose typed Suk rescue before equal actual Tank substitution; Face Dance follows original winner outcomes, kills/returns the winning trainer skill once without another bounty, and partial Cunning retains ordinary Mentat rights. Richese pair uses its original signed two-token frame/one invoice/native response/Guild continuation, not single-marker skill proof; CHOAM printed fuel/Planetologist physical route remain separate. No other overlays, companion/mixed-plan/new ruling/public starts or retrofit. | [Scope, sources and bounded evidence](NEXUS_CARD_RULES.md#paired-e1e2-nexus-and-leader-skills-composition--4-october-2026), [Skill runtime](LEADER_SKILLS_RUNTIME.md#paired-e1e2-nexus-composition--4-october-2026) | | Paired E1/E2 Nexus / Leader Skills | Fresh both Ixians+Tleilaxu OR CHOAM+Richese plus classics, one original family deck/all14 Skills/all12 Nexus, Basic/Advanced2..6, explicit original skill entry. Actual native setup/offers/closing draws remain. Full unsupported Suboids compose typed Suk rescue before equal actual Tank substitution; Face Dance follows original winner outcomes, kills/returns the winning trainer skill once without another bounty, and partial Cunning retains ordinary Mentat rights. Richese pair uses its original signed two-token frame/one invoice/native response/Guild continuation, not single-marker skill proof; CHOAM printed fuel/Planetologist physical route remain separate. No other overlays, companion/mixed-plan/new ruling/public starts or retrofit. | [Scope, sources and bounded evidence](NEXUS_CARD_RULES.md#paired-e1e2-nexus-and-leader-skills-composition--4-october-2026), [Skill runtime](LEADER_SKILLS_RUNTIME.md#paired-e1e2-nexus-composition--4-october-2026) |
| Classic Nexus / Discovery / original components | Fresh classic/base33/all12/Discovery7/8 Basic/Advanced2–6, original Tech3+/Advanced Strongholds2+, explicit `nexus --discoveries`. Original worm/vote/typed ride, next-turn entry, end-Mentat held custody and end-Spice closing draw after a Nexus and settled alliance remain. Extra/ordinary revival ledgers and borrowed Guild nested payment preserve native identity and physical outcomes. Skills/native/HW/variants/previews and unresolved mixed-module Fremen Betrayal remain gated; no new universal interruption priority. | [Composition contract and bounded evidence](DISCOVERY_PROTOTYPE.md#classic-nexus-composition--5-october-2026), [Nexus source contract](NEXUS_CARD_RULES.md#classic-discovery--original-tech-and-strongholds--5-october-2026) | | 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) |
| 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) | | 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) | | Discoveries | Preserve seven sourced Spice Cards/eight tokens, faction peeks and Collection choices. Fresh classic/native E1/E2/standalone E3 may retain original Tech3..6; separate fresh classic all14 Skills/Discovery/Tech uses original training, entry, movement, stash, rescue/rewards and Bureaucrat invoices. Native setup, revealed-only No-Fields and Moritani source histories remain. Advanced Orgiz uses the supplied p24 territory-Collection trigger; Basic stays provisional per deposit. Only Jacurutu adds a stronghold. Contested/shared/mixed cases, other combinations and public starts remain unfinished. | [Component authority](DISCOVERY_COMPONENTS.md), [native composition](DISCOVERY_PROTOTYPE.md#native-setup-and-original-consumers--5-october-2026), [classic Skills/Advanced source cutover](DISCOVERY_PROTOTYPE.md#classic-leader-skills-and-advanced-orgiz--5-october-2026) |
| Guild Secret Ally | One explicit Guild-priced ordinary shipment; borrowed prices/routes preserve native identity. Free Fremen reinforcement remains a separate choice. | [Route contract](NEXUS_GUILD_SECRET_ALLY_RULES.md), [verified runtime](NEXUS_GUILD_SECRET_ALLY_RUNTIME.md) | | Guild Secret Ally | One explicit Guild-priced ordinary shipment; borrowed prices/routes preserve native identity. Free Fremen reinforcement remains a separate choice. | [Route contract](NEXUS_GUILD_SECRET_ALLY_RULES.md), [verified runtime](NEXUS_GUILD_SECRET_ALLY_RUNTIME.md) |
| Homeworld custody and mobility | Physical home/visitor groups, population thresholds, tariffs and transport permissions retain distinct sources; Homeworld immunity is not universal immunity. | [Homeworld rules](HOMEWORLD_RULES.md), [benefits](HOMEWORLD_BENEFITS_RULES.md), [mobility](HOMEWORLD_MOBILITY_RULES.md), [invasion](HOMEWORLD_INVASION_RULES.md), [Guild transport](GUILD_HOMEWORLD_TRANSPORT_RULES.md) | | Homeworld custody and mobility | Physical home/visitor groups, population thresholds, tariffs and transport permissions retain distinct sources; Homeworld immunity is not universal immunity. | [Homeworld rules](HOMEWORLD_RULES.md), [benefits](HOMEWORLD_BENEFITS_RULES.md), [mobility](HOMEWORLD_MOBILITY_RULES.md), [invasion](HOMEWORLD_INVASION_RULES.md), [Guild transport](GUILD_HOMEWORLD_TRANSPORT_RULES.md) |
+13
View File
@@ -4,6 +4,19 @@
the base-faction Leader Skills preview connect the lower Smuggler effect in Basic the base-faction Leader Skills preview connect the lower Smuggler effect in Basic
and Advanced games. Public module, mode and publication gates remain closed. and Advanced games. Public module, mode and publication gates remain closed.
## Native Discovery legal participation — 6 October 2026
Three genuine native Skills/Discovery/Tech captures had legal hidden-trainer
plans but no bot candidate: the policy recomputed full-state module admission
against a player view that intentionally lacks private/internal configuration.
The engine now projects `smugglerCollectionSupported` beside the existing
saved battle format flag; bots use that authoritative rule result rather than
reconstructing the module profile. Modified-strength and multiple-positive-pile
guards still apply to each actual plan. No public mode or strategy tuning is
added. A real trainer-plan regression fails before and passes after repair;
all three original captures finish without rejection. Source trees/counts and
limits are in the [native Discovery evidence](DISCOVERY_PROTOTYPE.md#native-skill-runtime-evidence).
## Rule and timing contract ## Rule and timing contract
The [source follow-up](LEADER_SKILLS_RULES.md#20-september-smuggler-follow-up) The [source follow-up](LEADER_SKILLS_RULES.md#20-september-smuggler-follow-up)
+2 -2
View File
@@ -4,7 +4,7 @@ import { quoteEcazOccupyDial } from './ecaz-occupy-battle';
import { recruitsPlayAction } from './recruits'; import { recruitsPlayAction } from './recruits';
import type { Card } from './cards'; import type { Card } from './cards';
import { bureaucratBattlePenalty, canUsePlanetologistBattleSpecial, leaderSkillBattleBonus, usesSurvivingSkilledLeader } from './leader-skill-combat'; import { bureaucratBattlePenalty, canUsePlanetologistBattleSpecial, leaderSkillBattleBonus, usesSurvivingSkilledLeader } from './leader-skill-combat';
import { smugglerBattleModeSupported, smugglerBattlePlanBlock } from './smuggler-battle'; import { smugglerBattlePlanBlock } from './smuggler-battle';
import { battleCardSlotEligible, battleCategoryInspectionValue, fixedBattleInspectionMatches, validBattleSlotPair, type BattlePlanInspectionField } from './battle-card-slots'; import { battleCardSlotEligible, battleCategoryInspectionValue, fixedBattleInspectionMatches, validBattleSlotPair, type BattlePlanInspectionField } from './battle-card-slots';
import { mirrorWeaponModeBlock } from './mirror-weapon-mode'; import { mirrorWeaponModeBlock } from './mirror-weapon-mode';
import { defaultHarassWithdrawAllocation, quoteHarassWithdraw } from './harass-withdraw'; import { defaultHarassWithdrawAllocation, quoteHarassWithdraw } from './harass-withdraw';
@@ -809,7 +809,7 @@ function plans(g: GameView): Action[] {
const w = me.hand?.find((c) => c.id === weapon); const w = me.hand?.find((c) => c.id === weapon);
const d = me.hand?.find((c) => c.id === defense); const d = me.hand?.find((c) => c.id === defense);
if (b.smugglerCollectionEnabled && smugglerBattlePlanBlock({ assignments: battleSkills, if (b.smugglerCollectionEnabled && smugglerBattlePlanBlock({ assignments: battleSkills,
leader: myLeader, weapon: w, defense: d, kwisatz }, smugglerBattleModeSupported(g), leader: myLeader, weapon: w, defense: d, kwisatz }, b.smugglerCollectionSupported,
{ territory: b.territory, spice: g.spice })) continue; { territory: b.territory, spice: g.spice })) continue;
if (!battleCardSlotEligible('weapon', w, {planetologistWeapon: specialForLeader(w, leader)})) continue; if (!battleCardSlotEligible('weapon', w, {planetologistWeapon: specialForLeader(w, leader)})) continue;
const stoneBattle = const stoneBattle =
+2 -2
View File
@@ -1,4 +1,4 @@
import { classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, noOtherLeaderSkillModules, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile, type LeaderSkillProfile } from './leader-skill-profile'; import { classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, noOtherLeaderSkillModules, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile, type LeaderSkillProfile } from './leader-skill-profile';
import { classicDiscoveryLeaderSkillsProfile } from './discovery-module-profile'; import { classicDiscoveryLeaderSkillsProfile } from './discovery-module-profile';
export type BureaucratPaymentKind = 'auction' | 'shipment' | 'bribe' | 'revival'; export type BureaucratPaymentKind = 'auction' | 'shipment' | 'bribe' | 'revival';
export type BureaucratPaymentSource = { export type BureaucratPaymentSource = {
@@ -39,7 +39,7 @@ export function bureaucratPaymentModeSupported(game: LeaderSkillProfile): boolea
return (noOtherLeaderSkillModules(game) && return (noOtherLeaderSkillModules(game) &&
(!game.expansions.length || (!game.expansions.length ||
(game.expansions.length === 1 && game.expansions[0] === 'choam') || (game.expansions.length === 1 && game.expansions[0] === 'choam') ||
nativeExpansionLeaderSkillsProfile(game))) || classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game) || strongholdLeaderSkillsProfile(game) || classicNexusLeaderSkillsProfile(game) || standaloneE3NexusLeaderSkillsProfile(game) || classicDiscoveryLeaderSkillsProfile(game); nativeExpansionLeaderSkillsProfile(game))) || classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game) || strongholdLeaderSkillsProfile(game) || classicNexusLeaderSkillsProfile(game) || standaloneE3NexusLeaderSkillsProfile(game) || classicDiscoveryLeaderSkillsProfile(game) || nativeDiscoveryLeaderSkillsProfile(game);
} }
export function bureaucratUsed( export function bureaucratUsed(
used: readonly BureaucratPaymentUse[], used: readonly BureaucratPaymentUse[],
+10 -5
View File
@@ -1,7 +1,7 @@
import { classicNexusModulesProfile, pairedNexusModulesProfile } from './nexus-module-profile'; import { classicNexusModulesProfile, pairedNexusModulesProfile } from './nexus-module-profile';
import { nativeFactionTechProfile } from './faction-module-profile'; import { nativeFactionTechProfile } from './faction-module-profile';
import { classicDiscoveryLeaderSkillsProfile, classicDiscoveryNexusProfile, discoveryModeSupported, discoveryModuleProfile } from './discovery-module-profile'; import { classicDiscoveryLeaderSkillsProfile, classicDiscoveryNexusProfile, discoveryModeSupported, discoveryModuleProfile } from './discovery-module-profile';
import { advancedMoritaniLeaderSkillsProfile, advancedNativeLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, pairedNexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile } from './leader-skill-profile'; import { advancedMoritaniLeaderSkillsProfile, advancedNativeLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, pairedNexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile } from './leader-skill-profile';
import { bribeTimingBlock, maximumBribe, type BribeOptions } from './bribe-options'; import { bribeTimingBlock, maximumBribe, type BribeOptions } from './bribe-options';
import { quoteSpicePlacement, stormExposesTerritory, stormSectorAfter, wormConsumesForces } from './disaster-rules'; import { quoteSpicePlacement, stormExposesTerritory, stormSectorAfter, wormConsumesForces } from './disaster-rules';
import { isStormCardDistance, type StormCardComponent } from './storm-cards'; import { isStormCardDistance, type StormCardComponent } from './storm-cards';
@@ -7613,7 +7613,8 @@ function e3StrongholdFactionProfile(g: Game): boolean {
} }
function moritaniAssassinateModeSupported(g: Game) { function moritaniAssassinateModeSupported(g: Game) {
if (g.discoveryEnabled || g.discoveries) if (g.discoveryEnabled || g.discoveries)
return g.advanced && !!g.discoveries && discoveryModeSupported(g) && return g.advanced && !!g.discoveries &&
(discoveryModeSupported(g) || nativeDiscoveryLeaderSkillsProfile(g)) &&
g.players.some(player => player.faction === 'moritani'); g.players.some(player => player.faction === 'moritani');
return g.advanced && g.expansions.length === 1 && g.expansions[0] === 'ecaz' && return g.advanced && g.expansions.length === 1 && g.expansions[0] === 'ecaz' &&
g.players.some(p => p.faction === 'moritani') && g.players.some(p => p.faction === 'moritani') &&
@@ -9385,6 +9386,8 @@ function ecazOccupyCompositionSupported(g: Game): boolean {
g.players.some(p => p.faction === 'ecaz')) return true; g.players.some(p => p.faction === 'ecaz')) return true;
if (g.ecazOccupyPreview === true && g.discoveries && discoveryModeSupported(g) && if (g.ecazOccupyPreview === true && g.discoveries && discoveryModeSupported(g) &&
g.players.some(p => p.faction === 'ecaz')) return true; g.players.some(p => p.faction === 'ecaz')) return true;
if (g.discoveries && nativeDiscoveryLeaderSkillsProfile(g) &&
g.players.some(p => p.faction === 'ecaz')) return true;
return typeof g.advanced === 'boolean' && Array.isArray(g.expansions) && return typeof g.advanced === 'boolean' && Array.isArray(g.expansions) &&
g.expansions.includes('ecaz') && g.expansions.includes('ecaz') &&
g.expansions.every(id => ['ecaz', 'ix', 'choam'].includes(id)) && g.expansions.every(id => ['ecaz', 'ix', 'choam'].includes(id)) &&
@@ -9524,15 +9527,16 @@ export function initializeDiscoveryGameForAudit(state: Game): Game {
export function initializeLeaderSkillsGameForAudit(state: Game): Game { export function initializeLeaderSkillsGameForAudit(state: Game): Game {
requireRule(!state.leaderSkills, 'Leader Skills cannot redeal existing skill cards.'); requireRule(!state.leaderSkills, 'Leader Skills cannot redeal existing skill cards.');
if (state.discoveryEnabled) { if (state.discoveryEnabled) {
requireRule(classicDiscoveryLeaderSkillsProfile(state), 'Discovery Leader Skills require a fresh classic lobby with optional original Tech or Advanced Strongholds and no unrelated overlays.'); 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.');
requireFreshBaseRuntime(state); requireFreshBaseRuntime(state);
requireFreshFactionInventory(state); requireFreshFactionInventory(state);
} }
const g = structuredClone(state); const g = structuredClone(state);
g.leaderSkills = createLeaderSkills(random); g.leaderSkills = createLeaderSkills(random);
const initialized = initializeSetupGameForAudit(g, false, classicNexusLeaderSkillsProfile(g) || pairedNexusLeaderSkillsProfile(g) || standaloneE3NexusLeaderSkillsProfile(g), false, classicDiscoveryLeaderSkillsProfile(g), true, const initialized = initializeSetupGameForAudit(g, false, classicNexusLeaderSkillsProfile(g) || pairedNexusLeaderSkillsProfile(g) || standaloneE3NexusLeaderSkillsProfile(g), false, classicDiscoveryLeaderSkillsProfile(g) || nativeDiscoveryLeaderSkillsProfile(g), true,
g.expansions.length === 1 && g.expansions[0] === 'choam', nativeExpansionLeaderSkillsProfile(g)); g.expansions.length === 1 && g.expansions[0] === 'choam', nativeExpansionLeaderSkillsProfile(g));
if (advancedMoritaniLeaderSkillsProfile(initialized)) initializeMoritaniAssassinateState(initialized); if (advancedMoritaniLeaderSkillsProfile(initialized)) initializeMoritaniAssassinateState(initialized);
if (nativeDiscoveryLeaderSkillsProfile(initialized) && byFaction(initialized, 'ecaz')) initialized.ecazOccupyPreview = true;
return initialized; return initialized;
} }
/** Fresh all-fourteen-card classic or supported native skills; no public income toggle. */ /** Fresh all-fourteen-card classic or supported native skills; no public income toggle. */
@@ -9570,7 +9574,7 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
const skillTech = leaderSkills && (classicTechLeaderSkillsProfile(g) || nativeTechLeaderSkillsProfile(g) || skillStronghold || classicDiscoveryLeaderSkillsProfile(g)); const skillTech = leaderSkills && (classicTechLeaderSkillsProfile(g) || nativeTechLeaderSkillsProfile(g) || skillStronghold || classicDiscoveryLeaderSkillsProfile(g));
const nativeTech = factions && !leaderSkills && nativeFactionTechProfile(g); const nativeTech = factions && !leaderSkills && nativeFactionTechProfile(g);
const discoveryComposition = discovery && (discoveryModuleProfile(g) || const discoveryComposition = discovery && (discoveryModuleProfile(g) ||
classicDiscoveryNexusProfile(g) || (leaderSkills && classicDiscoveryLeaderSkillsProfile(g))); classicDiscoveryNexusProfile(g) || (leaderSkills && (classicDiscoveryLeaderSkillsProfile(g) || nativeDiscoveryLeaderSkillsProfile(g))));
const nexusModules = nexus && (classicNexusModulesProfile(g) || pairedNexusModulesProfile(g) || const nexusModules = nexus && (classicNexusModulesProfile(g) || pairedNexusModulesProfile(g) ||
(leaderSkills && (classicNexusLeaderSkillsProfile(g) || pairedNexusLeaderSkillsProfile(g) || standaloneE3NexusLeaderSkillsProfile(g)))); (leaderSkills && (classicNexusLeaderSkillsProfile(g) || pairedNexusLeaderSkillsProfile(g) || standaloneE3NexusLeaderSkillsProfile(g))));
requireRule(!g.techTokens || ((skillTech || nativeTech || nexusModules || discoveryComposition) && requireRule(!g.techTokens || ((skillTech || nativeTech || nexusModules || discoveryComposition) &&
@@ -32564,6 +32568,7 @@ export function viewGame(state: Game, id: string) {
: null, : null,
lateDefense: b.revealed ? (b.lateDefense ?? {}) : {}, lateDefense: b.revealed ? (b.lateDefense ?? {}) : {},
smugglerCollectionEnabled: b.smugglerCollectionVersion === 1, smugglerCollectionEnabled: b.smugglerCollectionVersion === 1,
smugglerCollectionSupported: smugglerBattleModeSupported(g),
smugglerCollection: b.revealed && b.smugglerCollection smugglerCollection: b.revealed && b.smugglerCollection
? { player: b.smugglerCollection.player, leader: b.smugglerCollection.leader, ? { player: b.smugglerCollection.player, leader: b.smugglerCollection.leader,
amount: b.smugglerCollection.amount, before: b.smugglerCollection.before } amount: b.smugglerCollection.amount, before: b.smugglerCollection.before }
+25 -1
View File
@@ -109,9 +109,27 @@ export function standaloneE3NexusLeaderSkillsProfile(game: LeaderSkillProfile):
(native === 'moritani' || !game.moritaniAssassinatePreview); (native === 'moritani' || !game.moritaniAssassinatePreview);
} }
/** Discovery keeps original native roster predicates; only this module envelope
* differs. Live token/entry/Great Maker frames are original consumers. */
function discoveryLeaderSkillModulesSupported(game: LeaderSkillProfile): boolean {
const players = game.players;
return typeof game.advanced === 'boolean' && game.discoveryEnabled === true &&
!!players && players.length >= 2 && players.length <= 6 &&
(!game.techTokens || players.length >= 3) &&
(!game.strongholdCards || game.advanced) &&
game.expansions.length >= 1 && game.expansions.length <= 2 &&
!game.homeworlds && !game.nexusCards && !game.ecazTreachery && !game.semutaPreview &&
!game.advancedPreview && !game.kullPreview && !game.nexusKullPreview &&
!game.guildBetrayalPreview && !game.richeseBetrayalPreview &&
!game.nexusIxianReplacementPreview && !game.nexusIxianBetrayalPreview &&
!game.nexusHarkonnenBetrayalPreview && !game.mentatQuestionPreview &&
!game.spiceBankerIncomePreview;
}
/** E1/E2 native skills may add Tech and Advanced Strongholds; original roster/decks remain. */ /** E1/E2 native skills may add Tech and Advanced Strongholds; original roster/decks remain. */
function nativeLeaderSkillModulesSupported(game: LeaderSkillProfile): boolean { function nativeLeaderSkillModulesSupported(game: LeaderSkillProfile): boolean {
if (noOtherLeaderSkillModules(game) || pairedNexusLeaderSkillsProfile(game)) return true; if (noOtherLeaderSkillModules(game) || pairedNexusLeaderSkillsProfile(game)) return true;
if (game.discoveryEnabled || game.discoveries) return discoveryLeaderSkillModulesSupported(game);
const players = game.players; const players = game.players;
if ((!game.techTokens && !game.strongholdCards) || !players || if ((!game.techTokens && !game.strongholdCards) || !players ||
players.length < (game.techTokens ? 3 : 2) || players.length > 6 || players.length < (game.techTokens ? 3 : 2) || players.length > 6 ||
@@ -133,6 +151,7 @@ function nativeLeaderSkillModulesSupported(game: LeaderSkillProfile): boolean {
/** Standalone E3 native predicates retain their own roster, Duke and assassination limits. */ /** Standalone E3 native predicates retain their own roster, Duke and assassination limits. */
function e3LeaderSkillModulesSupported(game: LeaderSkillProfile): boolean { function e3LeaderSkillModulesSupported(game: LeaderSkillProfile): boolean {
if (noOtherLeaderSkillModules(game) || standaloneE3NexusLeaderSkillsProfile(game)) return true; if (noOtherLeaderSkillModules(game) || standaloneE3NexusLeaderSkillsProfile(game)) return true;
if (game.discoveryEnabled || game.discoveries) return discoveryLeaderSkillModulesSupported(game);
const players = game.players; const players = game.players;
if ((!game.techTokens && !game.strongholdCards) || !players || if ((!game.techTokens && !game.strongholdCards) || !players ||
players.length < (game.techTokens ? 3 : 2) || players.length > 6 || players.length < (game.techTokens ? 3 : 2) || players.length > 6 ||
@@ -346,6 +365,11 @@ export function nativeExpansionLeaderSkillsProfile(game: LeaderSkillProfile): bo
advancedMoritaniLeaderSkillsProfile(game) || nativeEcazLeaderSkillsProfile(game); advancedMoritaniLeaderSkillsProfile(game) || nativeEcazLeaderSkillsProfile(game);
} }
/** Fresh/live original native all14/Discovery; existing family limits stay authoritative. */
export function nativeDiscoveryLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
return game.discoveryEnabled === true && nativeExpansionLeaderSkillsProfile(game);
}
/** Only the original eligible native skill families gain the Tech combination. */ /** Only the original eligible native skill families gain the Tech combination. */
export function nativeTechLeaderSkillsProfile(game: LeaderSkillProfile): boolean { export function nativeTechLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
return !!game.techTokens && nativeExpansionLeaderSkillsProfile(game); return !!game.techTokens && nativeExpansionLeaderSkillsProfile(game);
@@ -358,5 +382,5 @@ export function ordinaryLeaderSkillModeSupported(game: LeaderSkillProfile): bool
classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game) || classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game) ||
strongholdLeaderSkillsProfile(game) || classicNexusLeaderSkillsProfile(game) || strongholdLeaderSkillsProfile(game) || classicNexusLeaderSkillsProfile(game) ||
pairedNexusLeaderSkillsProfile(game) || standaloneE3NexusLeaderSkillsProfile(game) || pairedNexusLeaderSkillsProfile(game) || standaloneE3NexusLeaderSkillsProfile(game) ||
classicDiscoveryLeaderSkillsProfile(game); classicDiscoveryLeaderSkillsProfile(game) || nativeDiscoveryLeaderSkillsProfile(game);
} }
+4 -4
View File
@@ -4080,7 +4080,7 @@ export const RULE_TOPICS: RuleTopic[] = [
searchText: 'Hiereg Smuggler Jacurutu Sietch Cistern Ecological Testing Station Shrine Orgiz Processing Station Treachery Card Stash Spice Stash Ornithopter', searchText: 'Hiereg Smuggler Jacurutu Sietch Cistern Ecological Testing Station Shrine Orgiz Processing Station Treachery Card Stash Spice Stash Ornithopter',
steps: [ steps: [
'Fresh Discovery development setup supports classic, selected Ixian/Tleilaxu/CHOAM/Richese families, or standalone Ecaz or Moritani with classic opponents in Basic or Advanced, with seven extra Spice Cards and eight tokens. Original unused Tech Tokens may be selected with three through six seats; original Stronghold Cards require Advanced. Other modules, paired or mixed Ecaz/Moritani families and normal public starts stay gated.', 'Fresh Discovery development setup supports classic, selected Ixian/Tleilaxu/CHOAM/Richese families, or standalone Ecaz or Moritani with classic opponents in Basic or Advanced, with seven extra Spice Cards and eight tokens. Original unused Tech Tokens may be selected with three through six seats; original Stronghold Cards require Advanced. Other modules, paired or mixed Ecaz/Moritani families and normal public starts stay gated.',
'A separate fresh classic Leader Skills entry may explicitly add Discoveries, with the original base deck, all fourteen skill components, optional original Tech at three or more seats and Advanced Stronghold Cards. Existing trained posture, physical free entry, paid nested routes, stash capacity and skill battle outcomes remain. Native skill families, Nexus, Homeworlds and separate Banker/Mentat previews are outside this composition.', '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.', '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.',
'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.', '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.', '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.',
@@ -4101,7 +4101,7 @@ export const RULE_TOPICS: RuleTopic[] = [
checklist: [ checklist: [
{ {
area: 'Implementation', status: 'Partial', area: 'Implementation', status: 'Partial',
detail: 'Original classic/E1/E2/standalone E3 Discovery, separate classic Skills and classic Nexus compositions retain selected original Tech/Advanced Strongholds. Physical entry, nested marker/tariffs, stash limits, native outcomes, training, rescue and invoices use original consumers. Held benefits stay source-local; actual HMS copying and mandatory Ecaz coalition retain original payment/casualty/reward ordering. Advanced Orgiz aggregates one actual territory Collection; Basic stays provisional per deposit. Contested/shared/mixed cases remain pending.', 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.',
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'], 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'],
}, },
{ {
@@ -4121,8 +4121,8 @@ export const RULE_TOPICS: RuleTopic[] = [
}, },
{ {
area: 'Verification', status: 'Partial', area: 'Verification', status: 'Partial',
detail: 'Original programmes and phone controls cover native/skill/Nexus setup, physical entry, paid nested tariffs/No-Field, stash, trained and typed battles, HMS support, CHOAM invoice, source-local defense and original rewards. Five genuine six-seat Stronghold samples complete including one reproduced/repaired/resumed mandatory Ecaz battle; a sixth Richese sample retains its exhausted-cache ruling guard. Original two-game Nexus/Discovery proof and native repair/resume evidence remain separate. These are targeted proofs, not natural-every-effect, full combinations, assurance or deployed acceptance.', detail: 'Original native/skill/Nexus programmes and phone controls prove physical entry, paid tariffs/No-Field, stash, trained/typed rescue, HMS support, CHOAM invoices, source-local defense and native leader/reward transitions. All31 native skill six-seat samples complete after three reproduced/repaired/resumed hidden-Smuggler policy captures, across two source trees. Earlier classic/Nexus/Stronghold/native proofs and the exhausted-cache ruling capture remain separate. These are targeted proofs, not every-effect, full combinations, assurance or deployed acceptance.',
evidence: ['tests/discovery-skills-strongholds-runtime.test.ts', 'tests/discovery-native-strongholds-runtime.test.ts', 'tests/ecaz-stronghold-runtime.test.ts', 'tests/e3-native-tech-runtime.test.ts', 'tests/discovery-classic-nexus-lifecycle.test.ts', 'tests/discovery-classic-nexus-effects.test.ts', 'tests/discovery-orgiz-advanced-runtime.test.ts', 'tests/discovery-classic-skills-runtime.test.ts', 'tests/discovery-module-profile.test.ts', 'tests/discovery-native-typed-runtime.test.ts', 'tests/discovery-native-marker-runtime.test.ts', 'tests/discovery-native-e3-runtime.test.ts', 'tests/discoveries.test.ts', 'tests/discovery-runtime.test.ts', 'tests/great-maker.test.ts', 'tests/discovery-board.test.ts', 'tests/discovery-admission.test.ts', 'tests/discovery-controls.test.ts', 'tests/discovery-entry.test.ts', 'tests/discovery-entry-runtime.test.ts', 'tests/discovery-entry-controls.test.ts', 'tests/discovery-entry-review.test.ts', 'tests/discovery-collection.test.ts', 'tests/discovery-flight.test.ts', 'tests/discovery-flight-engine.test.ts', 'tests/discovery-flight-recovery.test.ts', 'tests/discovery-battle.test.ts', 'tests/discovery-battle-recovery.test.ts', 'tests/discovery-storm.test.ts', 'tests/discovery-storm-recovery.test.ts', 'tests/discovery-shrine-engine.test.ts', 'tests/discovery-shrine-recovery.test.ts'], evidence: ['tests/discovery-native-e1-skills-runtime.test.ts', 'tests/discovery-native-e2-skills-runtime.test.ts', 'tests/discovery-native-e3-skills-runtime.test.ts', 'tests/discovery-skills-strongholds-runtime.test.ts', 'tests/discovery-native-strongholds-runtime.test.ts', 'tests/ecaz-stronghold-runtime.test.ts', 'tests/e3-native-tech-runtime.test.ts', 'tests/discovery-classic-nexus-lifecycle.test.ts', 'tests/discovery-classic-nexus-effects.test.ts', 'tests/discovery-orgiz-advanced-runtime.test.ts', 'tests/discovery-classic-skills-runtime.test.ts', 'tests/discovery-module-profile.test.ts', 'tests/discovery-native-typed-runtime.test.ts', 'tests/discovery-native-marker-runtime.test.ts', 'tests/discovery-native-e3-runtime.test.ts', 'tests/discoveries.test.ts', 'tests/discovery-runtime.test.ts', 'tests/great-maker.test.ts', 'tests/discovery-board.test.ts', 'tests/discovery-admission.test.ts', 'tests/discovery-controls.test.ts', 'tests/discovery-entry.test.ts', 'tests/discovery-entry-runtime.test.ts', 'tests/discovery-entry-controls.test.ts', 'tests/discovery-entry-review.test.ts', 'tests/discovery-collection.test.ts', 'tests/discovery-flight.test.ts', 'tests/discovery-flight-engine.test.ts', 'tests/discovery-flight-recovery.test.ts', 'tests/discovery-battle.test.ts', 'tests/discovery-battle-recovery.test.ts', 'tests/discovery-storm.test.ts', 'tests/discovery-storm-recovery.test.ts', 'tests/discovery-shrine-engine.test.ts', 'tests/discovery-shrine-recovery.test.ts'],
}, },
], ],
}, },
+3 -3
View File
@@ -52,9 +52,9 @@ void test('Discovery cannot redeal started components or silently remove another
for (const game of overlays) assert.throws(() => initializeDiscoveryGameForAudit(game), RuleError); for (const game of overlays) assert.throws(() => initializeDiscoveryGameForAudit(game), RuleError);
}); });
void test('classic Discovery Skills does not silently drop native families or unrelated modules and previews', () => { void test('Discovery Skills cannot silently drop unsupported native families or unrelated modules and previews', () => {
const native = lobby(['ixians', 'guild', 'fremen'], ['ix']); const unsupported = lobby(['ixians', 'ecaz', 'guild'], ['ix', 'ecaz']);
assert.throws(() => initializeLeaderSkillsGameForAudit(native), RuleError); assert.throws(() => initializeLeaderSkillsGameForAudit(unsupported), RuleError);
const overlays = Array.from({ length: 5 }, () => lobby(['guild', 'emperor', 'harkonnen'], [])); const overlays = Array.from({ length: 5 }, () => lobby(['guild', 'emperor', 'harkonnen'], []));
overlays[0].homeworlds = { custody: null }; overlays[0].homeworlds = { custody: null };
overlays[1].nexusCards = { cards: null, phase: null }; overlays[1].nexusCards = { cards: null, phase: null };
@@ -0,0 +1,324 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { gameDistance, MOBILE_LOCATION, MOBILE_STRONGHOLD, splitLocation, territory as boardTerritory } from '../game/board';
import { FACTIONS } from '../game/catalog';
import { DISCOVERY_SPICE_CARDS } from '../game/discoveries';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { nativeTypedClearBoard, nativeTypedPlace } from './fixture-discovery-native-typed';
import { openSkillsTechBattle } from './fixture-skills-tech-battle';
import {
advanceDiscoveryNativeE1Skills as advance, assertDiscoveryNativeE1SkillsCustody as custody,
createDiscoveryNativeE1SkillsBattle as battle, createDiscoveryNativeE1SkillsEntry as entry,
discoveryNativeE1PlanetologistRoute, discoveryNativeE1SkillsPlayer as player,
finishDiscoveryNativeE1SkillsBattle as finish, movementDiscoveryNativeE1Skills as movement,
phaseDiscoveryNativeE1Skills as phase, reloadDiscoveryNativeE1Skills as reload,
revealDiscoveryNativeE1SkillsBattle as reveal, settleDiscoveryNativeE1Skills as settle,
stepDiscoveryNativeE1Skills as step,
} from './fixture-discovery-native-e1-skills';
function reject(game: Game, actor: string, action: Action): void {
const before = reload(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before, 'Invalid native selections cannot partially debit or move physical inventory');
}
function acceptEntry(game: Game, source: string): Action {
assert.ok(game.decision?.kind === 'discoveryEntry');
return { type: 'decision', event: game.decision.event, accept: true,
groups: [{ source, normal: 1, elite: 1 }] };
}
void test('original E1 all14 offers precede native Traitors and preserve first-Storm Tech and real first-Mentat retained cards', () => {
for (const advanced of [false, true]) for (const tech of [false, true]) {
const f = entry({ advanced, tech, strongholds: advanced });
const skills = f.offered.leaderSkills!;
assert.deepEqual([...skills.deck, ...Object.values(skills.offers).flatMap(o => o.cards)].sort(), LEADER_SKILL_CARDS.map(c => c.id).sort());
assert.ok(f.offered.players.every(p => !p.traitorChoices.length && !p.traitors.length));
assert.equal(player(f.offered, f.opponent).faceDancers, undefined);
assert.equal(f.offered.discoveries!.tokens.length, 8);
assert.deepEqual(f.offered.spiceDeck.flatMap(c => 'territory' in c && c.discovery ? [c.discovery] : []).sort(),
DISCOVERY_SPICE_CARDS.map(c => c.discovery).sort());
assert.equal(f.offered.spiceDeck.filter(c => 'sandtrout' in c && c.sandtrout).length, 1);
assert.equal(player(f.afterSetup, f.opponent).faceDancers!.length, 3);
assert.equal(player(f.afterSetup, f.owner).forces[MOBILE_LOCATION], 6);
assert.equal(player(f.afterSetup, f.owner).elites!.forces[MOBILE_LOCATION], 3);
for (const p of f.afterSetup.players) assert.equal(p.spice, FACTIONS.find(c => c.id === p.faction)!.spice);
if (tech) {
assert.ok(Object.values(f.initial.techTokens!).every(t => t.owner === null));
assert.equal(f.afterFirstStorm.techTokens!.heighliners.owner, f.owner);
assert.equal(f.afterFirstStorm.techTokens!.axlotl.owner, f.opponent);
assert.equal(f.afterFirstStorm.techTokens!.production.owner, f.fremen);
}
if (advanced) {
assert.equal(f.beforeFirstMentat.strongholdCards!.owners.arrakeen, null);
const claimed = step(f.beforeFirstMentat, f.firstMentatStep);
assert.equal(claimed.turn, 2);
assert.equal(claimed.strongholdCards!.owners.arrakeen, f.owner);
assert.equal(claimed.strongholdCards!.owners[MOBILE_STRONGHOLD], f.owner);
assert.equal(f.game.strongholdCards!.owners.sietch_tabr, null);
}
custody(f.afterSetup); custody(f.game);
}
});
void test('authenticated original lobby and original undealt setup retain actual IDs and authoritative offers', () => {
const initial = createGame('HUMANE1DISCOVERY', newPlayer('human-ix', 'Human Ix', 'ixians'), true, ['ix']);
for (const [id, faction] of [['human-tl', 'tleilaxu'], ['human-gu', 'guild'], ['human-fr', 'fremen']] as const)
joinGame(initial, newPlayer(id, faction, faction));
const before = reload(initial), f = entry({ initial, tech: true, strongholds: true });
assert.deepEqual(initial, before);
assert.deepEqual(f.game.players.map(p => p.id), before.players.map(p => p.id));
assert.equal(f.owner, 'human-ix');
const saved = reload(f.offered), resumed = entry({ initial: saved, skill: 'suk-graduate' });
assert.deepEqual(saved, f.offered);
assert.deepEqual(resumed.offered.leaderSkills, saved.leaderSkills, 'Never redeal an authenticated offer');
assert.equal(resumed.trainer, f.trainer);
custody(resumed.game);
let randomLobby = reload(initial);
randomLobby.discoveryEnabled = true;
randomLobby = applyAction(randomLobby, randomLobby.host, { type: 'techTokens', enabled: true });
randomLobby = applyAction(randomLobby, randomLobby.host, { type: 'strongholdCards', enabled: true });
for (const p of randomLobby.players) randomLobby = applyAction(randomLobby, p.id, { type: 'ready' });
const randomSetup = initializeLeaderSkillsGameForAudit(randomLobby), originalOffers = reload(randomSetup);
const actual = entry({ initial: randomSetup });
assert.deepEqual(randomSetup, originalOffers);
assert.deepEqual(actual.offered.leaderSkills, originalOffers.leaderSkills);
const entered = applyAction(actual.game, actual.owner, acceptEntry(actual.game, actual.source));
assert.equal(player(entered, actual.owner).forces['shrine:0'], 2);
assert.equal(player(entered, actual.owner).elites!.forces['shrine:0'], 1);
custody(entered);
});
void test('trained original Ix typed free entry moves selected counters once without consuming training, shipment, movement, wallets or Tech', () => {
for (const advanced of [false, true]) {
const f = entry({ advanced, tech: true, strongholds: advanced });
const before = player(f.game, f.owner), action = acceptEntry(f.game, f.source);
assert.deepEqual(viewGame(f.game, f.owner).discoveryEntry!.sources, [{ source: f.source, normal: 2, elite: 1 }]);
reject(f.game, f.opponent, action);
reject(f.game, f.owner, { ...action, groups: [{ source: f.source, normal: 1, elite: 2 }] });
const arrived = applyAction(reload(f.game), f.owner, action), own = player(arrived, f.owner);
assert.equal(own.forces['shrine:0'], 2);
assert.equal(own.elites!.forces['shrine:0'], 1);
assert.equal(own.forces[f.source], 1);
assert.equal(own.elites!.forces[f.source] ?? 0, 0);
assert.equal(own.spice, before.spice);
assert.equal(own.reserves, before.reserves);
assert.equal(own.moved, before.moved);
assert.equal(own.shipped, before.shipped);
assert.deepEqual(arrived.leaderSkills, f.game.leaderSkills);
assert.deepEqual(arrived.techTokens, f.game.techTokens);
assert.equal(arrived.discoveryEntry, undefined);
reject(arrived, f.owner, action);
custody(arrived);
}
});
void test('original paid typed shipment and Planetologist three-territory nested route retain HMS identity and accrue only native Heighliners', () => {
for (const advanced of [false, true]) {
const f = entry({ advanced, tech: true, strongholds: advanced, skill: 'planetologist' });
assert.ok(f.game.decision?.kind === 'discoveryEntry');
let game = applyAction(f.game, f.owner, { type: 'decision', event: f.game.decision.event, accept: false });
game = movement(game, f.owner);
nativeTypedClearBoard(game);
const route = discoveryNativeE1PlanetologistRoute(game);
nativeTypedPlace(game, f.owner, route.source, 3, 1);
assert.equal(gameDistance(game, route.source, 'shrine:0', key => splitLocation(key).sector === game.storm), 3);
const hms = player(game, f.owner).forces[MOBILE_LOCATION], beforeShip = reload(game);
game = settle(applyAction(game, f.owner, { type: 'ship', territory: 'shrine', sector: 0, amount: 2, elite: 1 }));
assert.equal(player(game, f.owner).spice, player(beforeShip, f.owner).spice - 2);
assert.equal(player(game, f.guild).spice, player(beforeShip, f.guild).spice + 2);
assert.equal(player(game, f.owner).reserves, player(beforeShip, f.owner).reserves - 2);
assert.equal(player(game, f.owner).elites!.reserves, player(beforeShip, f.owner).elites!.reserves - 1);
const moving = reload(game), move: Action = { type: 'move', territory: 'shrine', sector: 0,
forces: { [route.source]: 2 }, eliteForces: { [route.source]: 1 }, planetologist: 'range' };
reject(game, f.owner, { ...move, planetologist: undefined });
game = settle(applyAction(game, f.owner, move));
assert.equal(player(game, f.owner).forces['shrine:0'], 4);
assert.equal(player(game, f.owner).elites!.forces['shrine:0'], 2);
assert.equal(player(game, f.owner).forces[route.source], 2);
assert.equal(player(game, f.owner).forces[MOBILE_LOCATION], hms);
assert.equal(player(game, f.owner).spice, player(moving, f.owner).spice);
assert.equal(player(game, f.owner).moved, 1);
assert.equal(game.techTokens!.heighliners.spice, 1);
const wallet = player(game, f.owner).spice;
game = advance(game, g => g.phase !== 5);
assert.equal(game.techTokens!.heighliners.spice, 0);
assert.equal(player(game, f.owner).spice, wallet + 1);
assert.deepEqual(game.leaderSkills, moving.leaderSkills);
custody(game);
}
});
void test('original trained Suk settles fixed physical rescue before only genuine Cyborg Tank losses can be substituted inside Shrine', () => {
for (const advanced of [false, true]) for (const tech of [false, true]) {
const f = battle({ advanced, tech, strongholds: advanced });
const revealed = reveal(f), pending = finish(revealed, 'rescue');
assert.ok(pending.decision?.kind === 'sukRescue');
const own = player(pending, f.owner);
assert.deepEqual(pending.pendingSukRescue!.losses, { normal: 4, elite: 2, paidNormal: 0, paidElite: advanced ? 1 : 0 });
assert.equal(own.tanks, 0);
assert.equal(own.forces[f.location], 8, 'Counters remain physically committed until the real rescue');
assert.equal(pending.pendingIxSubstitution, undefined);
assert.equal(own.spice, player(revealed, f.owner).spice - (advanced ? 1 : 0) +
player(revealed, f.opponent).leaders.find(l => l.id === f.losingLeader)!.strength,
'Retained Arrakeen cannot subsidize a nested site');
const choice = pending.decision.options.findIndex(o => o.normal === 2 && o.elite === 1 && o.kept?.kind === 'normal');
assert.ok(choice >= 0);
reject(pending, f.owner, { type: 'decision', sources: { [f.location]: 1 }, recover: { [f.location]: 1 } });
const rescueAction: Action = { type: 'decision', event: pending.decision.event, choice };
const rescued = applyAction(reload(pending), f.owner, rescueAction), after = player(rescued, f.owner);
assert.equal(rescued.decision?.kind, 'ixSubstitution');
assert.equal(rescued.lastBattleContext!.sukRescue!.completed, true);
assert.equal(after.forces[f.location], 3);
assert.equal(after.elites!.forces[f.location] ?? 0, 0);
assert.equal(after.reserves, own.reserves + 2);
assert.equal(after.elites!.reserves, own.elites!.reserves + 1);
assert.equal(after.tanks, 3);
assert.equal(after.elites!.tanks, 1);
assert.deepEqual(rescued.pendingIxSubstitution!.losses, { [f.location]: 1 });
reject(rescued, f.owner, rescueAction);
reject(rescued, f.owner, { type: 'decision', sources: { [f.location]: 1 }, recover: { [f.replacementSource]: 1 } });
reject(rescued, f.owner, { type: 'decision', sources: { [f.location]: 2 }, recover: { [f.location]: 2 } });
const substituted = settle(applyAction(reload(rescued), f.owner, { type: 'decision',
sources: { [f.location]: 1 }, recover: { [f.location]: 1 } }));
assert.equal(player(substituted, f.owner).forces[f.location], 3);
assert.equal(player(substituted, f.owner).elites!.forces[f.location], 1);
assert.equal(player(substituted, f.owner).tanks, 3);
assert.equal(player(substituted, f.owner).elites!.tanks, 0);
assert.equal(player(substituted, f.owner).spice, after.spice);
assert.equal(substituted.lastBattleContext!.ixSubstitution!.completed, true);
custody(substituted);
}
});
void test('green original Ix Planetologist waits through native substitution then disposes exactly once before optional winner cleanup and Tech', () => {
for (const advanced of [false, true]) {
const f = battle({ advanced, tech: true, strongholds: advanced, skill: 'planetologist' });
const pending = finish(reveal(f), 'substitution');
assert.equal(pending.decision?.kind, 'ixSubstitution');
assert.ok(player(pending, f.owner).hand.some(c => c.id === f.weapon));
assert.ok(pending.pendingWinnerDiscards!.cards.includes(f.weapon));
assert.equal(pending.techTokens!.axlotl.owner, f.opponent);
const cards = finish(applyAction(reload(pending), f.owner, { type: 'decision', decline: true }), 'cards');
assert.equal(cards.decision?.kind, 'battleCards');
assert.equal(cards.discard.filter(c => c.id === f.weapon).length, 1);
assert.equal(player(cards, f.owner).hand.some(c => c.id === f.weapon), false);
assert.equal(cards.lastBattleContext!.winnerDiscards!.completed, true);
assert.equal(player(cards, f.owner).forces[f.location], 2);
assert.equal(player(cards, f.owner).tanks, 6);
reject(cards, f.owner, { type: 'decision', discard: [f.weapon] });
const face = finish(applyAction(cards, f.owner, { type: 'decision', discard: [f.defense] }), 'faceDance');
assert.equal(face.techTokens!.axlotl.owner, f.owner);
assert.equal(face.discard.filter(c => c.id === f.weapon).length, 1);
assert.equal(face.discard.filter(c => c.id === f.defense).length, 1);
assert.equal(face.discard.filter(c => c.id === f.losingCard).length, 1);
assert.equal(face.decision?.kind, 'faceDance');
custody(face);
}
});
void test('matching native Face Dance kills the original trainer after rescue/cards/Tech, returns training once and retains native replacement eligibility', () => {
for (const advanced of [false, true]) {
const f = battle({ advanced, tech: true, strongholds: advanced });
const cards = finish(reveal(f), 'cards');
assert.equal(cards.decision?.kind, 'battleCards');
const face = finish(applyAction(cards, f.owner, { type: 'decision', discard: [f.defense] }), 'faceDance');
assert.equal(face.lastBattleContext!.winner, f.owner);
assert.equal(face.techTokens!.axlotl.owner, f.owner);
assert.ok(player(face, f.owner).hand.some(c => c.id === f.weapon));
const own = player(face, f.owner), dancer = player(face, f.opponent);
const action: Action = { type: 'decision', reveal: true, sector: 0,
sources: { reserves: 1, [f.replacementSource]: 2 } };
reject(face, f.opponent, { ...action, sector: 1 });
const danced = applyAction(reload(face), f.opponent, action);
assert.equal(player(danced, f.owner).leaders.find(l => l.id === f.trainer)!.dead, true);
assert.equal(danced.leaderSkills!.assignments.some(a => a.leader === f.trainer), false);
assert.equal(danced.leaderSkills!.deck.filter(s => s === f.skill).length, 1);
assert.equal(player(danced, f.owner).reserves, own.reserves + 3);
assert.equal(player(danced, f.owner).forces[f.location] ?? 0, 0);
assert.equal(player(danced, f.opponent).forces[f.location], 3);
assert.equal(player(danced, f.opponent).forces[f.replacementSource], 1);
assert.equal(player(danced, f.opponent).reserves, dancer.reserves - 1);
for (const p of face.players) assert.equal(player(danced, p.id).spice, p.spice, 'No second death bounty, subsidy or support debit');
assert.deepEqual(danced.techTokens, face.techTokens);
assert.deepEqual(player(danced, f.owner).hand, own.hand);
reject(danced, f.opponent, action);
const mentat = phase(danced, 8), stock = player(mentat, f.opponent).faceDancers!;
const revealedDancer = stock.find(c => c.leader === f.trainer); assert.ok(revealedDancer?.revealed);
reject(mentat, f.opponent, { type: 'replaceFaceDancer', leader: f.trainer });
const untouched = stock.find(c => !c.revealed); assert.ok(untouched);
const stockBefore = stock.map(c => ({ ...c }));
const replaced = settle(applyAction(mentat, f.opponent, { type: 'replaceFaceDancer', leader: untouched.leader }));
assert.equal(player(replaced, f.opponent).faceDancerReplacedTurn, replaced.turn);
assert.deepEqual(player(replaced, f.opponent).faceDancers!.filter(c => c.leader === f.trainer), [revealedDancer]);
for (const c of stockBefore.filter(c => c.leader !== untouched.leader))
assert.ok(player(replaced, f.opponent).faceDancers!.some(n => n.leader === c.leader && n.revealed === c.revealed));
reject(replaced, f.opponent, { type: 'replaceFaceDancer', leader: player(replaced, f.opponent).faceDancers!.find(c => !c.revealed)!.leader });
custody(replaced);
if (!advanced) {
const revival = advance(replaced, g => g.turn === 3 && g.phase === 4 && !g.phaseOpening && !g.response && !g.decision);
assert.equal(player(revival, f.owner).leaders.find(l => l.id === f.trainer)!.dead, true);
reject(revival, f.opponent, { type: 'reviveForeignGhola', leader: f.trainer });
assert.equal(revival.leaderSkills!.assignments.some(a => a.leader === f.trainer), false,
'Basic native Tleilaxu cannot turn a matching dead trainer into a foreign skill carrier');
custody(revival);
}
}
});
void test('all four original minimal policies produce real typed entry and capped trained rescue outcomes rather than action-list echoes', () => {
const f = entry({ advanced: true, tech: true, strongholds: true });
const b = battle({ advanced: true, tech: true, strongholds: true }), pending = finish(reveal(b), 'rescue');
assert.ok(pending.decision?.kind === 'sukRescue');
assert.equal(DIFFICULTIES.length, 4);
for (const difficulty of DIFFICULTIES) {
const entryView = viewGame(f.game, f.owner); entryView.players.find(p => p.id === f.owner)!.bot = difficulty;
const entryAction = botActions(entryView)[0]; assert.ok(entryAction);
const entered = applyAction(reload(f.game), f.owner, entryAction), before = player(f.game, f.owner), own = player(entered, f.owner);
assert.equal(entered.discoveryEntry, undefined);
const arrived = own.forces['shrine:0'] ?? 0;
assert.equal((own.forces[f.source] ?? 0) + arrived, before.forces[f.source]);
assert.equal((own.elites!.forces[f.source] ?? 0) + (own.elites!.forces['shrine:0'] ?? 0), 1);
assert.equal(own.spice, before.spice);
assert.equal(own.moved, before.moved);
assert.equal(own.shipped, before.shipped);
const rescueView = viewGame(pending, b.owner); rescueView.players.find(p => p.id === b.owner)!.bot = difficulty;
const rescueAction = botActions(rescueView)[0]; assert.ok(rescueAction);
const rescued = applyAction(reload(pending), b.owner, rescueAction);
assert.equal(rescued.pendingSukRescue, null);
assert.equal(rescued.lastBattleContext!.sukRescue!.completed, true);
assert.equal(player(rescued, b.owner).tanks, 3, `${difficulty}: exactly three of six committed casualties are saved`);
assert.equal(player(rescued, b.owner).forces[b.location], 3, `${difficulty}: exactly one rescue is kept in the original nested sector`);
assert.equal(player(rescued, b.owner).reserves, player(pending, b.owner).reserves + 2);
assert.equal(player(rescued, b.owner).spice, player(pending, b.owner).spice);
const completed = finish(rescued);
assert.equal(completed.lastBattleContext!.winner, b.owner);
assert.equal(player(completed, b.opponent).tanks, 8);
assert.equal(completed.techTokens!.axlotl.owner, b.owner);
custody(entered); custody(completed);
}
});
void test('hidden native Smuggler policy seals a real legal trainer plan in Discovery without applying full-state mode rules to a private player view', () => {
const f = entry({ advanced: true, tech: true, strongholds: true, skill: 'smuggler' });
let game = movement(f.game, f.owner);
nativeTypedClearBoard(game);
for (const seat of game.players) { game.deck.push(...seat.hand); seat.hand = []; }
const territory = 'habbanya_ridge_sietch', key = `${territory}:${boardTerritory(territory).sectors[0]}`;
nativeTypedPlace(game, f.owner, key, 3);
nativeTypedPlace(game, f.opponent, key, 3);
game = openSkillsTechBattle(phase(game, 6), f.owner, f.opponent, territory, true);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, f.owner);
view.players.find(p => p.id === f.owner)!.bot = difficulty;
const plan = botActions(view).find(action => action.type === 'battlePlan' && action.leader === f.trainer);
assert.ok(plan, `${difficulty} must retain the actually available hidden trainer as a legal plan candidate.`);
const committed = applyAction(reload(game), f.owner, plan);
assert.equal(committed.battle!.plans[f.owner].leader, f.trainer);
assert.equal(player(committed, f.owner).forces[key], 3);
assert.equal(player(committed, f.owner).spice, player(game, f.owner).spice,
'A sealed first plan does not debit support or collect unearned Smuggler spice.');
}
});
@@ -0,0 +1,330 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, handLimit, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { treacheryDeck, spiceDeck } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import { traitorDeck } from '../game/traitors';
import { DISCOVERY_SPICE_CARDS, validateDiscoveryState } from '../game/discoveries';
import { validateLeaderSkills } from '../game/leader-skills';
import { validateRicheseNoField } from '../game/richese-no-field';
import { createTechTokens, ownedTech, TECH_TOKENS } from '../game/tech-tokens';
import { strongholdBenefit, strongholdControllers } from '../game/stronghold-cards';
import { canUseAsKaramaRole, canUseAsTruthtranceRole } from '../game/shrine';
import {
advanceDiscoveryNativeE2Skills, completeDiscoveryNativeE2SkillsSetup, createDiscoveryNativeE2SkillsFixture,
discoveryNativeE2SkillsInvoiceAction, discoveryNativeE2SkillsMovementWindow,
discoveryNativeE2SkillsPlayer as player, finishDiscoveryNativeE2SkillsBattle,
holdDiscoveryNativeE2SkillsCard, prepareDiscoveryNativeE2SkillsMarkerBattle,
revealDiscoveryNativeE2Skills, settleDiscoveryNativeE2Skills,
} from './fixture-discovery-native-e2-skills';
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
function reject(game: Game, actor: string, action: Action): void {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
function custody(game: Game): void {
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
...(game.richeseCache ?? []), ...(game.richeseRemoved ?? [])];
assert.deepEqual(cards.map(card => card.id).sort(),
[...treacheryDeck(game.expansions), ...richeseCards()].map(card => card.id).sort());
assert.equal(new Set(cards.map(card => card.id)).size, cards.length);
for (const p of game.players) {
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((total, amount) => total + amount, 0), 20);
if (p.noField) validateRicheseNoField(p.noField);
}
validateLeaderSkills(game.leaderSkills!, game.players);
validateDiscoveryState(game.discoveries!);
if (game.status === 'playing') {
const held = [...(game.traitorReserve ?? []), ...game.players.flatMap(p => [...p.traitors, ...p.traitorChoices])];
assert.deepEqual(held.sort(), traitorDeck(game.players, game.expansions.includes('ix')).sort());
assert.equal(new Set(held).size, held.length);
}
}
// Source composition: LEADER_SKILLS_RUNTIME.md native Richese/Advanced CHOAM,
// CHOAM_LEADER_SKILLS.md, DISCOVERY_COMPONENTS.md and original native marker
// fixture. No Banker income, Mentat questions or mixed No-Field interpretation.
for (const advanced of [false, true]) for (const tech of [false, true]) {
void test(`${advanced ? 'Advanced' : 'Basic'} original all14 E2/Discovery preserves independent ordinary/cache custody with ${tech ? 'native Tech' : 'no Tech'}`, () => {
const f = createDiscoveryNativeE2SkillsFixture({ advanced, tech, strongholds: advanced });
const ordinary = [...f.setup.deck, ...f.setup.players.flatMap(p => p.hand)];
assert.equal(ordinary.length, 35);
assert.deepEqual(ordinary.map(c => c.id).sort(), treacheryDeck(['choam']).map(c => c.id).sort());
assert.deepEqual(f.setup.richeseCache!.map(c => c.id).sort(), richeseCards().map(c => c.id).sort());
assert.equal(f.setup.richeseCache!.length, 10);
assert.equal(f.setup.leaderSkills!.deck.length + Object.values(f.setup.leaderSkills!.offers).reduce((n, o) => n + o.cards.length, 0), 14);
assert.ok(f.setup.players.every(p => p.traitors.length === 0 && p.traitorChoices.length === 0));
assert.equal(f.setup.spiceDeck.length, spiceDeck().length + 7);
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.discoveries!.tokens.length, 8);
assert.equal(viewGame(f.setup, f.richese).leaderSkills!.offer!.cards.includes('planetologist'), true);
assert.equal(viewGame(f.setup, f.choam).leaderSkills!.offer!.cards.includes('smuggler'), true);
assert.ok(player(f.game, f.richese).hand.some(c => c.id === f.green));
assert.equal(f.game.richeseCache!.some(c => c.id === f.green), false);
assert.ok(f.game.richeseCache!.length > 0, 'Two-turn bounded recipes do not bypass the exhausted-cache ruling.');
assert.equal(f.game.spiceBankerIncomePreview, undefined);
assert.equal(f.game.mentatQuestionPreview, undefined);
if (tech) {
assert.deepEqual(f.setup.techTokens, createTechTokens());
assert.ok(TECH_TOKENS.every(token => f.afterFirstStorm.techTokens![token.id].owner !== null));
assert.ok(TECH_TOKENS.every(token => f.afterFirstStorm.players.some(p => p.id === f.afterFirstStorm.techTokens![token.id].owner)));
} else assert.equal(f.afterFirstStorm.techTokens, undefined);
assert.deepEqual(f.afterSetup.leaderSkills!.assignments, f.game.leaderSkills!.assignments);
custody(f.setup);
custody(f.game);
});
}
void test('actual original offers can train and finish native setup without a controlled shuffle or demanded skill', () => {
const f = createDiscoveryNativeE2SkillsFixture({ advanced: true, kind: 'stash', useActualOffers: true });
const offered = structuredClone(f.setup.leaderSkills!.offers);
const after = completeDiscoveryNativeE2SkillsSetup(reload(f.setup), true);
for (const assignment of after.leaderSkills!.assignments) {
assert.ok(offered[assignment.owner].cards.includes(assignment.skill));
assert.ok(player(f.setup, assignment.owner).leaders.some(l => l.id === assignment.leader));
}
assert.equal(after.leaderSkills!.assignments.length, after.players.length);
assert.deepEqual(after.players.flatMap(p => p.hand).map(c => c.id).sort(), f.setup.players.flatMap(p => p.hand).map(c => c.id).sort());
assert.equal(Object.keys(after.leaderSkills!.offers).length, 0);
custody(after);
custody(f.game);
});
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} CHOAM skill owner opens the native five-card Stash boundary, then every minimal policy disposes its actual sixth card once`, () => {
const f = createDiscoveryNativeE2SkillsFixture({ advanced, kind: 'stash', tech: true, strongholds: advanced });
const before = f.game, choam = player(before, f.choam);
assert.equal(handLimit(choam), 5);
while (choam.hand.length < handLimit(choam)) {
const card = before.deck.shift();
assert.ok(card); choam.hand.push(card);
}
f.staging.push('Conserved original undealt ordinary cards into CHOAM hand at its native five-card capacity.');
const wallet = choam.spice, hand = choam.hand.map(c => c.id), drawn = before.deck[0];
assert.ok(drawn);
const revealed = revealDiscoveryNativeE2Skills(f);
assert.equal(revealed.decision?.kind, 'discoveryDiscard');
assert.equal(revealed.decision?.player, f.choam);
assert.deepEqual(player(revealed, f.choam).hand.map(c => c.id), [...hand, drawn.id]);
assert.equal(player(revealed, f.choam).spice, wallet);
assert.equal(revealed.deck.length, before.deck.length - 1);
assert.equal(viewGame(revealed, f.choam).players.find(p => p.id === f.choam)!.hand!.length, 6);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(revealed, f.choam);
view.players.find(p => p.id === f.choam)!.bot = difficulty;
const action = botActions(view).find(a => a.type === 'decision' && typeof a.card === 'string');
assert.ok(action, `${difficulty} must return a real owned disposal action.`);
const done = settleDiscoveryNativeE2Skills(applyAction(reload(revealed), f.choam, action));
assert.equal(player(done, f.choam).hand.length, 5);
assert.equal(player(done, f.choam).spice, wallet);
assert.equal(player(done, f.choam).hand.some(c => c.id === action.card), false);
assert.equal(done.discard.filter(c => c.id === action.card).length, 1);
assert.equal(done.discoveryStash!.stage, 'complete');
assert.equal(done.discoveries!.tokens.find(t => t.id === f.token)!.status, 'removed');
assert.deepEqual(done.leaderSkills!.assignments, revealed.leaderSkills!.assignments);
reject(done, f.choam, action);
reject(done, f.choam, { type: 'discovery', token: f.token, reveal: true });
custody(done);
}
for (const physical of player(revealed, f.choam).hand) {
const action: Action = { type: 'decision', event: revealed.discoveryStash!.event, card: physical.id };
reject(revealed, f.richese, action);
reject(revealed, f.choam, { ...action, event: 'stale-stash' });
const done = settleDiscoveryNativeE2Skills(applyAction(reload(revealed), f.choam, action));
assert.equal(done.discard.filter(c => c.id === physical.id).length, 1);
assert.equal(player(done, f.choam).hand.length, 5);
assert.equal(done.deck.length, before.deck.length - 1);
custody(done);
}
custody(revealed);
});
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} original first end-Mentat claims and source-local Shrine/Stronghold entitlement precede trained CHOAM invoice and Bureaucrat diversion`, () => {
const f = createDiscoveryNativeE2SkillsFixture({ advanced, kind: 'invoice', tech: true, strongholds: advanced });
const checkpoint = discoveryNativeE2SkillsMovementWindow(f, f.choam), before = checkpoint.game;
assert.deepEqual(f.setup.techTokens, createTechTokens());
assert.ok(TECH_TOKENS.every(t => f.afterFirstStorm.techTokens![t.id].owner !== null));
if (advanced) {
assert.equal(checkpoint.beforeFirstMentat.strongholdCards!.claimedTurn, 0);
assert.equal(checkpoint.afterFirstMentat.strongholdCards!.claimedTurn, 1);
const expected = strongholdControllers(checkpoint.beforeFirstMentat.players, false);
assert.deepEqual(checkpoint.afterFirstMentat.strongholdCards!.owners, expected);
const claims = Object.entries(expected).filter(([, owner]) => owner);
assert.ok(claims.length > 0);
for (const [territory, owner] of claims) {
assert.equal(strongholdBenefit(checkpoint.afterFirstMentat.strongholdCards, owner!, territory), territory);
assert.equal(strongholdBenefit(checkpoint.afterFirstMentat.strongholdCards, owner!, 'shrine'), null);
assert.equal(strongholdBenefit(checkpoint.afterFirstMentat.strongholdCards, f.observer === owner ? f.choam : f.observer, territory), null);
}
}
assert.equal(player(before, f.guild).forces['shrine:0'], 1);
const truth = holdDiscoveryNativeE2SkillsCard(before, f.guild, c => c.effect === 'truthtrance', f.staging);
const karama = holdDiscoveryNativeE2SkillsCard(before, f.guild, c => c.effect === 'karama', f.staging);
assert.equal(canUseAsKaramaRole(before, player(before, f.guild), truth), true);
assert.equal(canUseAsTruthtranceRole(before, player(before, f.guild), karama), true);
assert.equal(canUseAsKaramaRole(before, player(before, f.choam), truth), false);
assert.equal(canUseAsTruthtranceRole(before, player(before, f.choam), karama), false);
const invoice = discoveryNativeE2SkillsInvoiceAction(before);
const own = structuredClone(player(before, f.choam)), recipient = player(before, f.guild).spice;
let paid = applyAction(reload(before), f.choam, invoice.action);
paid = advanceDiscoveryNativeE2Skills(paid, g => g.decision?.kind === 'bureaucratPayment');
assert.ok(paid.decision?.kind === 'bureaucratPayment');
const pending = paid.bureaucratPayments!.pending!;
assert.deepEqual({ kind: pending.source.kind, payer: pending.source.payer, payee: pending.source.payee, amount: pending.source.amount },
{ kind: 'shipment', payer: f.choam, payee: f.guild, amount: 5 });
assert.equal(pending.owner, f.observer);
assert.equal(player(paid, f.choam).spice, own.spice - 5);
assert.equal(player(paid, f.choam).reserves, own.reserves - 6);
assert.equal(player(paid, f.choam).forces[invoice.key], 6);
assert.equal(player(paid, f.guild).spice, recipient);
for (const redirect of [false, true]) {
const decision: Action = { type: 'decision', event: paid.decision!.event, redirect };
reject(paid, f.choam, decision);
reject(paid, f.observer, { ...decision, event: 'stale-payment' });
const done = settleDiscoveryNativeE2Skills(applyAction(reload(paid), f.observer, decision));
assert.equal(player(done, f.choam).spice, own.spice - 5);
assert.equal(player(done, f.guild).spice, recipient + (redirect ? 3 : 5));
assert.equal(player(done, f.observer).spice, player(paid, f.observer).spice);
assert.equal(done.bureaucratPayments!.used.length, redirect ? 1 : 0);
assert.equal(done.bureaucratPayments!.pending, undefined);
assert.equal(done.techTokens!.heighliners.spice, paid.techTokens!.heighliners.spice);
reject(done, f.observer, decision);
custody(done);
}
for (const difficulty of DIFFICULTIES) {
const view = viewGame(paid, f.observer);
view.players.find(p => p.id === f.observer)!.bot = difficulty;
const action = botActions(view).find(a => a.type === 'decision' && typeof a.redirect === 'boolean');
assert.ok(action);
const done = settleDiscoveryNativeE2Skills(applyAction(reload(paid), f.observer, action));
assert.equal(player(done, f.guild).spice, recipient + (action.redirect ? 3 : 5));
assert.equal(player(done, f.choam).forces[invoice.key], 6);
assert.equal(player(done, f.choam).spice, own.spice - 5);
custody(done);
}
});
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} actual CHOAM Kulon fuel preserves the trained invoice, discards once and grants its native movement bonus`, () => {
const f = createDiscoveryNativeE2SkillsFixture({ advanced, kind: 'invoice' });
const checkpoint = discoveryNativeE2SkillsMovementWindow(f, f.choam);
const before = checkpoint.game;
const fuel = holdDiscoveryNativeE2SkillsCard(before, f.choam, c => c.name === 'Kulon' && c.kind === 'worthless', f.staging);
// Keep a real cancellation opportunity open to observe held fuel, not a fake receipt.
holdDiscoveryNativeE2SkillsCard(before, f.observer, c => c.effect === 'karama', f.staging);
const action: Action = { type: 'card', mode: 'choam', card: fuel.id };
const wallet = player(before, f.choam).spice, reserves = player(before, f.choam).reserves;
const requested = applyAction(reload(before), f.choam, action);
assert.equal(requested.response?.kind, 'choamWorthless');
assert.deepEqual(player(requested, f.choam).hand.find(c => c.id === fuel.id), fuel);
assert.equal(requested.discard.some(c => c.id === fuel.id), false);
const done = settleDiscoveryNativeE2Skills(requested);
assert.equal(done.discard.filter(c => c.id === fuel.id).length, 1);
assert.equal(player(done, f.choam).hand.some(c => c.id === fuel.id), false);
assert.equal(done.choamMovement!.bonus, 1);
assert.equal(player(done, f.choam).spice, wallet);
assert.equal(player(done, f.choam).reserves, reserves);
assert.deepEqual(done.leaderSkills!.assignments, before.leaderSkills!.assignments);
reject(done, f.choam, action);
const invoice = discoveryNativeE2SkillsInvoiceAction(done);
const paid = settleDiscoveryNativeE2Skills(applyAction(reload(done), f.choam, invoice.action));
assert.equal(player(paid, f.choam).spice, wallet - 5);
assert.equal(player(paid, f.choam).reserves, reserves - 6);
assert.equal(player(paid, f.choam).forces[invoice.key], 6);
assert.equal(paid.choamMovement!.bonus, 1);
custody(paid);
});
for (const advanced of [false, true]) for (const marker of [0, 3, 5] as const) {
void test(`${advanced ? 'Advanced' : 'Basic'} nested marker-only ${marker} keeps paid custody until both plans then applies capped physical reveal and canonical green Planetologist strength`, () => {
const f = createDiscoveryNativeE2SkillsFixture({ advanced, tech: true, strongholds: advanced });
const battle = prepareDiscoveryNativeE2SkillsMarkerBattle(f, { marker, ...(marker === 5 ? { reserveCap: 3 } : {}) });
const before = player(battle.beforeShipment, f.richese), shipped = player(battle.afterShipment, f.richese);
assert.equal(shipped.spice, before.spice - 1, 'Original Discovery locations use the stronghold shipment price, independently of victory eligibility.');
assert.equal(shipped.reserves, before.reserves);
assert.deepEqual(shipped.forces, before.forces);
assert.deepEqual(shipped.noField!.tokens, before.noField!.tokens);
assert.deepEqual(shipped.noField!.deployed!.location, { territory: 'shrine', sector: 0 });
assert.equal(shipped.noField!.deployed!.tokenId, battle.token);
const privateOpponent = viewGame(battle.game, f.guild);
assert.equal(privateOpponent.richeseNoField!.private, null);
assert.equal(privateOpponent.richeseNoField!.public.deployed!.effectiveForces, 1);
reject(battle.game, f.richese, { ...battle.ownerPlan, dial: battle.materialized + 1, support: advanced ? battle.materialized + 1 : 0 });
let game = applyAction(reload(battle.game), f.richese, battle.ownerPlan);
assert.ok(player(game, f.richese).noField!.deployed);
assert.equal(player(game, f.richese).forces[battle.key] ?? 0, 0);
assert.equal(player(game, f.richese).reserves, shipped.reserves);
assert.ok(player(game, f.richese).hand.some(c => c.id === f.green));
game = applyAction(reload(game), f.guild, battle.opponentPlan);
assert.equal(player(game, f.richese).noField!.deployed, null);
assert.equal(player(game, f.richese).forces[battle.key] ?? 0, battle.materialized);
assert.equal(player(game, f.richese).reserves, shipped.reserves - battle.materialized);
assert.deepEqual(player(game, f.richese).noField!.tokens, before.noField!.tokens);
game = finishDiscoveryNativeE2SkillsBattle(reload(game), 'cleanup');
assert.equal(game.lastBattleContext!.winner, f.richese);
const trainer = player(battle.game, f.richese).leaders.find(l => l.id === f.trainers.richese)!;
assert.equal(player(game, f.richese).leaders.find(l => l.id === trainer.id)!.dead, false);
assert.equal(player(game, f.guild).leaders.find(l => l.id === battle.opponentPlan.leader)!.dead, false);
assert.equal(player(game, f.richese).tanks, before.tanks);
assert.equal(player(game, f.guild).tanks, player(battle.game, f.guild).tanks + 8);
assert.equal(player(game, f.richese).spice, player(battle.game, f.richese).spice, 'Zero support and inert green substitution charge no native fee or death bounty.');
assert.equal(game.discard.filter(c => c.id === f.green).length, 1);
assert.equal(player(game, f.richese).hand.some(c => c.id === f.green), false);
const noGreenPlan = { ...battle.ownerPlan };
delete noGreenPlan.weapon;
let ordinary = applyAction(reload(battle.game), f.richese, noGreenPlan);
ordinary = applyAction(ordinary, f.guild, battle.opponentPlan);
ordinary = finishDiscoveryNativeE2SkillsBattle(ordinary, 'cleanup');
assert.equal(ordinary.lastBattleContext!.winner, f.guild, 'Removing only the physical green substitute loses the same real battle.');
assert.equal(player(ordinary, f.richese).hand.some(c => c.id === f.green), true);
assert.equal(ordinary.discard.some(c => c.id === f.green), false);
custody(ordinary);
const loserTokens = ownedTech(battle.game.techTokens, f.guild);
const done = finishDiscoveryNativeE2SkillsBattle(reload(game));
if (loserTokens.length) {
const changed = TECH_TOKENS.filter(t => battle.game.techTokens![t.id].owner !== done.techTokens![t.id].owner);
assert.equal(changed.length, 1);
assert.ok(loserTokens.includes(changed[0].id));
assert.equal(done.techTokens![changed[0].id].owner, f.richese);
}
assert.equal(done.discard.filter(c => c.id === f.green).length, 1);
assert.deepEqual(normalizeAutomaticGame(reload(done)), done);
custody(done);
});
}
void test('four minimal policies resolve actual nested marker plans into physical revelation and a completed casualty/skill consumer', () => {
const f = createDiscoveryNativeE2SkillsFixture({ advanced: true, tech: true, strongholds: true });
const battle = prepareDiscoveryNativeE2SkillsMarkerBattle(f, { marker: 5, reserveCap: 3 });
for (const difficulty of DIFFICULTIES) {
let game = reload(battle.game);
for (let step = 0; !game.battle?.revealed && step < 100; step++) {
let advanced = false;
for (const seat of game.players) {
const view = viewGame(game, seat.id);
view.players.find(p => p.id === seat.id)!.bot = difficulty;
const action = botActions(view)[0];
if (!action) continue;
game = applyAction(game, seat.id, action);
advanced = true;
break;
}
assert.ok(advanced, `${difficulty} needs an actual owned card, response or plan continuation.`);
}
assert.ok(game.battle?.revealed);
assert.equal(player(game, f.richese).noField!.deployed, null);
assert.equal(player(game, f.richese).forces[battle.key], 3);
assert.equal(player(game, f.richese).reserves, 0);
const done = finishDiscoveryNativeE2SkillsBattle(game);
assert.ok(done.lastBattleContext?.winner === f.richese || done.lastBattleContext?.winner === f.guild);
assert.equal(done.battle, null);
assert.equal(player(done, f.richese).noField!.deployed, null);
assert.equal(player(done, f.richese).noField!.lastShipped, battle.token);
assert.equal(player(done, f.richese).tanks + (player(done, f.richese).forces[battle.key] ?? 0), 3);
assert.deepEqual(player(done, f.richese).noField!.tokens, player(battle.game, f.richese).noField!.tokens);
custody(done);
}
});
@@ -0,0 +1,279 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
import { leaders, treacheryDeck } from '../game/cards';
import { DIFFICULTIES } from '../game/bot-profiles';
import type { FactionId } from '../game/catalog';
import { validateDiscoveryState } from '../game/discoveries';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { validateLeaderSkills } from '../game/leader-skills';
import { strongholdControllers } from '../game/stronghold-cards';
import { ownedTech } from '../game/tech-tokens';
import { traitorDeck } from '../game/traitors';
import { quoteStrongholdFactionsBattle } from './fixture-stronghold-factions';
import { holdMoritaniStrongholdsTraitor } from './fixture-moritani-strongholds';
import {
advanceDiscoveryNativeE3Skills as advance, completeDiscoveryNativeE3SkillsSetup,
createDiscoveryNativeE3SkillsFixture as fixture, createDiscoveryNativeE3SkillsRevivalFixture,
discoveryNativeE3SkillsClean as clean, discoveryNativeE3SkillsPlayer as player,
discoveryNativeE3SkillsPolicy as policy, initializeDiscoveryNativeE3Skills,
nextDiscoveryNativeE3SkillsStep, resolveDiscoveryNativeE3SkillsBattle,
finishDiscoveryNativeE3SkillsBattles,
revealDiscoveryNativeE3SkillsPlans,
type DiscoveryNativeE3SkillsOptions,
} from './fixture-discovery-native-e3-skills';
function custody(game: Game): void {
validateLeaderSkills(game.leaderSkills!, game.players); validateDiscoveryState(game.discoveries!);
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
...(game.auction?.cards.slice(game.auction.index) ?? [])].map(c => c.id).sort();
assert.deepEqual(cards, treacheryDeck(['ecaz']).map(c => c.id).sort());
const skills = [...game.leaderSkills!.deck, ...Object.values(game.leaderSkills!.offers).flatMap(o => o.cards),
...game.leaderSkills!.assignments.map(a => a.skill)].sort();
assert.deepEqual(skills, LEADER_SKILL_CARDS.map(c => c.id).sort());
const retired = game.moritaniAssassinate?.opportunities.filter(receipt => receipt.stage === 'replaced').map(receipt => receipt.card!) ?? [];
assert.deepEqual([...game.traitorReserve!, ...game.players.flatMap(p => [...p.traitors, ...p.traitorChoices]), ...retired].sort(),
traitorDeck(game.players).sort());
for (const p of game.players) {
assert.deepEqual(p.leaders.map(l => l.id).sort(), leaders(p.faction).map(l => l.id).sort());
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0), 20);
if (p.elites) {
assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((a, b) => a + b, 0), p.faction === 'fremen' ? 3 : 5);
for (const [key, amount] of Object.entries(p.elites.forces)) assert.ok(amount <= (p.forces[key] ?? 0));
}
}
}
function assassinate(game: Game, card: string | null): Game {
const d = game.decision; assert.equal(d?.kind, 'moritaniAssassinate');
if (d?.kind !== 'moritaniAssassinate') throw Error('Expected actual original nested loss opportunity.');
return applyAction(structuredClone(game), d.player, { type: 'decision', event: d.event,
...(card === null ? { decline: true } : { card }) });
}
void test('authenticated original Moritani/classic Discovery preserves native all14 training before Traitors at Basic/Advanced module seat boundaries', () => {
const cases: { options: DiscoveryNativeE3SkillsOptions; counts: number[] }[] = [
{ options: { rules: 'basic', tech: false, strongholds: false }, counts: [2, 6] },
{ options: { rules: 'basic', tech: true, strongholds: false }, counts: [3, 6] },
{ options: { rules: 'advanced', tech: false, strongholds: false }, counts: [2, 6] },
{ options: { rules: 'advanced', tech: true, strongholds: false }, counts: [3, 6] },
{ options: { rules: 'advanced', tech: false, strongholds: true }, counts: [2, 6] },
{ options: { rules: 'advanced', tech: true, strongholds: true }, counts: [3, 6] },
];
const factions: FactionId[] = ['moritani', 'guild', 'emperor', 'atreides', 'fremen', 'beneGesserit'];
for (const { options, counts } of cases) for (const count of counts) {
const lobby = createGame('AUTHENTICATEDE3SKILLS', newPlayer('original-m', 'Original Moritani', 'moritani'), options.rules === 'advanced', ['ecaz']);
for (let i = 1; i < count; i++) joinGame(lobby, newPlayer(`original-${i}`, `Original ${factions[i]}`, factions[i]));
const before = structuredClone(lobby), setup = initializeDiscoveryNativeE3Skills({ ...options, initial: lobby });
assert.deepEqual(lobby, before);
assert.deepEqual(setup.players.map(p => ({ id: p.id, name: p.name, faction: p.faction })),
before.players.map(p => ({ id: p.id, name: p.name, faction: p.faction })));
assert.ok(setup.players.every(p => p.traitors.length === 0));
const after = completeDiscoveryNativeE3SkillsSetup(setup), guild = after.players.find(p => p.faction === 'guild')!.id;
assert.equal(after.leaderSkills!.assignments.find(a => a.owner === guild)!.skill, 'suk-graduate');
assert.equal(after.players.find(p => p.faction === 'moritani')!.reserves, 14);
assert.equal(Object.values(after.players.find(p => p.faction === 'moritani')!.forces).reduce((a, b) => a + b, 0), 6);
assert.equal(!!after.moritaniAssassinate, options.rules === 'advanced');
if (after.strongholdCards) assert.ok(Object.values(after.strongholdCards.owners).every(owner => owner === null));
custody(after);
}
});
void test('printed Discovery reveal, actual first-Storm Tech/END-Mentat claims, free entry and paid nested shipment preserve source positions and native wallets', () => {
const f = fixture({ tech: true, strongholds: true });
assert.ok(Object.values(f.setup.techTokens!).every(t => t.owner === null));
for (const p of f.afterFirstStorm.players) assert.equal(ownedTech(f.afterFirstStorm.techTokens, p.id).length, 1);
assert.ok(Object.values(f.beforeFirstMentat.strongholdCards!.owners).every(owner => owner === null));
const claimed = applyAction(structuredClone(f.beforeFirstMentat), f.firstMentatStep.actor, f.firstMentatStep.action);
assert.deepEqual(claimed.strongholdCards!.owners, strongholdControllers(f.beforeFirstMentat.players, false));
assert.equal(claimed.strongholdCards!.owners.tueks_sietch, f.moritani);
const revealedToken = f.afterReveal.discoveries!.tokens.find(t => t.id === f.token)!;
assert.equal(revealedToken.territory, f.source.split(':')[0]);
assert.equal(player(f.beforeReveal, f.opponent).forces[f.source], 3);
assert.equal(player(f.entry.after, f.opponent).forces[f.source] ?? 0, 0);
assert.equal(player(f.entry.after, f.opponent).forces['cistern:0'], 3);
assert.equal(player(f.entry.after, f.opponent).spice, player(f.entry.before, f.opponent).spice);
assert.equal(player(f.entry.after, f.opponent).reserves, player(f.entry.before, f.opponent).reserves);
assert.equal(player(f.shipment.after, f.moritani).forces['cistern:0'], 1);
assert.equal(player(f.shipment.after, f.moritani).reserves, player(f.shipment.before, f.moritani).reserves - 1);
assert.equal(player(f.shipment.after, f.moritani).spice, player(f.shipment.before, f.moritani).spice - 1);
for (const game of [f.afterSetup, f.afterFirstStorm, f.afterReveal, f.entry.after, f.shipment.after, f.pending]) custody(game);
});
void test('normal Guild Suk resolves physical casualty rescue BEFORE nested after-loss assassination; printed3 bounty and exact skill return never replay support or casualties', () => {
const f = fixture({ tech: true, strongholds: true }), pending = f.pending;
assert.equal(pending.lastBattleContext?.winner, f.opponent);
assert.equal(pending.lastBattleContext?.result, 'normal');
assert.equal(pending.lastBattleContext?.sukRescue?.completed, true);
assert.equal(player(pending, f.opponent).tanks, player(f.revealed, f.opponent).tanks + 2);
assert.equal(player(pending, f.opponent).reserves, player(f.revealed, f.opponent).reserves + 1);
assert.equal(player(pending, f.opponent).forces[f.location], 3);
assert.equal(player(pending, f.moritani).tanks, player(f.revealed, f.moritani).tanks + 6);
assert.equal(player(pending, f.opponent).spice, player(f.revealed, f.opponent).spice - 3);
assert.equal(player(pending, f.moritani).spice, player(f.revealed, f.moritani).spice - 1);
assert.equal(viewGame(pending, f.moritani).moritaniAssassinate!.pending!.territory, 'cistern');
const target = player(pending, f.opponent).leaders.find(l => l.id === f.target)!;
assert.equal(target.name, 'Master Bewt'); assert.equal(target.strength, 3); assert.notEqual(target.id, f.opponentLeader);
const originalCounters = pending.players.map(p => ({ id: p.id, forces: p.forces, reserves: p.reserves, tanks: p.tanks }));
const reserve = [...pending.traitorReserve!], ownTraitors = [...player(pending, f.moritani).traitors];
let game = assassinate(pending, f.target);
assert.equal(player(game, f.opponent).leaders.find(l => l.id === f.target)!.dead, true);
assert.equal(player(game, f.opponent).leaders.find(l => l.id === f.target)!.deaths, target.deaths + 1);
assert.equal(game.leaderSkills!.assignments.some(a => a.leader === f.target), false);
assert.equal(game.leaderSkills!.deck.filter(c => c === 'suk-graduate').length, 1);
assert.equal(player(game, f.moritani).spice, player(pending, f.moritani).spice + target.strength);
assert.equal(player(game, f.opponent).spice, player(pending, f.opponent).spice);
assert.deepEqual(game.players.map(p => ({ id: p.id, forces: p.forces, reserves: p.reserves, tanks: p.tanks })), originalCounters);
assert.deepEqual(game.techTokens, pending.techTokens); assert.deepEqual(game.strongholdCards, pending.strongholdCards);
assert.equal(game.decision?.kind, 'battleCards'); assert.equal(game.decision?.player, f.opponent);
game = applyAction(structuredClone(game), f.opponent, { type: 'decision', discard: f.opponentCards });
game = advance(game, g => !g.battle && clean(g));
const lost = ownedTech(pending.techTokens, f.moritani)[0]; assert.ok(lost);
assert.equal(game.techTokens![lost].owner, f.opponent);
for (const card of [...f.moritaniCards, ...f.opponentCards]) assert.equal(game.discard.filter(c => c.id === card).length, 1);
game = finishDiscoveryNativeE3SkillsBattles(game);
game = advance(game, g => g.phase === 8);
assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.territory, 'cistern');
assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.stage, 'replaced');
assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.replacement, reserve[0]);
assert.deepEqual(game.traitorReserve, reserve.slice(1));
assert.deepEqual(player(game, f.moritani).traitors, [...ownTraitors.filter(c => c !== f.target), reserve[0]]);
assert.equal('replacement' in viewGame(game, f.opponent).moritaniAssassinate!.history.at(-1)!, false);
assert.equal(game.leaderSkills!.deck.filter(c => c === 'suk-graduate').length, 1);
game = advance(game, g => g.turn === 3 && g.phase === 4 && clean(g));
const before = structuredClone(game);
assert.throws(() => applyAction(game, f.opponent, { type: 'reviveLeader', leader: f.target }), /All leaders must die/);
assert.deepEqual(game, before); custody(game);
});
void test('earned Tuek Worthless benefits remain source-local: retained card pays in Tuek, never in the separately revealed Cistern', () => {
for (const battle of ['nested', 'tueks_sietch'] as const) {
const f = fixture({ strongholds: true, battle });
assert.equal(f.revealed.strongholdCards!.owners.tueks_sietch, f.moritani);
assert.deepEqual(quoteStrongholdFactionsBattle(f.revealed).strongholdIncome,
battle === 'nested' ? [] : [{ player: f.moritani, amount: 4 }]);
assert.equal(player(f.pending, f.moritani).spice, player(f.revealed, f.moritani).spice - 1 + (battle === 'nested' ? 0 : 4));
assert.equal(player(f.pending, f.opponent).tanks, 2);
assert.equal(f.pending.lastBattleContext?.winner, f.opponent);
custody(f.pending);
}
});
void test('Basic Discovery keeps normal Suk and optional original Tech capture without Advanced support, Strongholds or assassination', () => {
for (const tech of [false, true]) {
const f = fixture({ rules: 'basic', tech, strongholds: false });
assert.equal(f.pending.lastBattleContext?.winner, f.opponent);
assert.equal(f.pending.lastBattleContext?.sukRescue?.completed, true);
assert.equal(player(f.pending, f.opponent).tanks, 2); assert.equal(player(f.pending, f.moritani).tanks, 6);
assert.equal(player(f.pending, f.opponent).spice, player(f.revealed, f.opponent).spice);
assert.equal(player(f.pending, f.moritani).spice, player(f.revealed, f.moritani).spice);
assert.equal(f.pending.moritaniAssassinate, undefined); assert.ok(!f.pending.strongholdCards);
assert.equal(f.pending.leaderSkills!.assignments.find(a => a.leader === f.target)!.skill, 'suk-graduate');
if (tech) {
const lost = ownedTech(f.revealed.techTokens, f.moritani)[0]; assert.ok(lost);
assert.equal(f.pending.techTokens![lost].owner, f.opponent);
}
custody(f.pending);
}
});
void test('a normal native Traitor reveal forfeits assassination for the entire game, including a later real nested loss', () => {
const f = fixture({ normalCall: true });
assert.equal(f.pending.lastBattleContext?.result, 'traitor');
assert.equal(f.pending.lastBattleContext?.winner, f.moritani);
assert.equal(f.pending.moritaniAssassinate!.normalTraitorCall, true);
assert.equal(player(f.pending, f.opponent).leaders.find(l => l.id === f.opponentLeader)!.dead, true);
let game = advance(finishDiscoveryNativeE3SkillsBattles(structuredClone(f.pending)), g => g.turn === 3 && g.phase === 5 && clean(g));
for (const p of game.players) {
p.reserves += Object.values(p.forces).reduce((a, b) => a + b, 0); p.forces = {};
if (p.elites) { p.elites.reserves += Object.values(p.elites.forces).reduce((a, b) => a + b, 0); p.elites.forces = {}; }
if (p.advisors) p.advisors = {};
}
for (const actor of [f.moritani, f.opponent]) { const p = player(game, actor); p.reserves -= 4; p.forces['cistern:0'] = 4; }
holdMoritaniStrongholdsTraitor(game, f.moritani, f.target);
const trained = new Set(game.leaderSkills!.assignments.map(a => a.leader));
const own = player(game, f.moritani).leaders.filter(l => !l.dead && !trained.has(l.id)).sort((a, b) => a.strength - b.strength)[0];
const enemy = player(game, f.opponent).leaders.filter(l => !l.dead && !trained.has(l.id)).sort((a, b) => b.strength - a.strength)[0];
assert.ok(own && enemy);
game = advance(game, g => g.phase === 6 && clean(g)); const actor = game.active!;
game = applyAction(game, actor, { type: 'chooseBattle', territory: 'cistern', target: actor === f.moritani ? f.opponent : f.moritani });
game = advance(game, g => !!g.battle && !g.battle.revealed && !nextDiscoveryNativeE3SkillsStep(g));
game = revealDiscoveryNativeE3SkillsPlans(game, [
{ actor: f.moritani, action: { type: 'battlePlan', leader: own.id, dial: 0, support: 0 } },
{ actor: f.opponent, action: { type: 'battlePlan', leader: enemy.id, dial: 0, support: 0 } },
]);
game = resolveDiscoveryNativeE3SkillsBattle(game, f.moritani);
assert.equal(game.lastBattleContext?.result, 'normal'); assert.equal(game.lastBattleContext?.winner, f.opponent);
assert.equal(game.moritaniAssassinate!.normalTraitorCall, true);
assert.equal(game.moritaniAssassinateCallEvents!.length, 1);
assert.deepEqual(game.moritaniAssassinate!.opportunities, []);
assert.equal(player(game, f.opponent).leaders.find(l => l.id === f.target)!.dead, false); custody(game);
});
void test('four minimal policies actually kill the eligible trained disc or decline the real used-disc-only menu; neither branch replays native resources', () => {
const f = fixture({ tech: true, strongholds: true });
let empty = structuredClone(f.game);
holdMoritaniStrongholdsTraitor(empty, f.moritani, f.opponentLeader);
empty = resolveDiscoveryNativeE3SkillsBattle(revealDiscoveryNativeE3SkillsPlans(empty, f.plans), f.moritani);
assert.deepEqual(viewGame(empty, f.moritani).moritaniAssassinate!.pending!.cards, []);
for (const difficulty of DIFFICULTIES) {
const action = policy(structuredClone(f.pending), f.moritani, difficulty)[0]; assert.ok(action, difficulty);
let game = applyAction(structuredClone(f.pending), f.moritani, action);
assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.stage, 'revealed');
assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.card, f.target);
assert.equal(player(game, f.opponent).leaders.find(l => l.id === f.target)!.dead, true);
assert.equal(player(game, f.moritani).spice, player(f.pending, f.moritani).spice + 3);
assert.equal(game.leaderSkills!.deck.filter(c => c === 'suk-graduate').length, 1);
game = advance(game, g => !g.battle && clean(g));
assert.equal(player(game, f.opponent).tanks, 2); assert.equal(player(game, f.moritani).tanks, 6); custody(game);
const decline = policy(structuredClone(empty), f.moritani, difficulty)[0]; assert.ok(decline, difficulty);
const declined = applyAction(structuredClone(empty), f.moritani, decline);
assert.equal(declined.moritaniAssassinate!.opportunities.at(-1)!.stage, 'declined');
assert.deepEqual(declined.leaderSkills, empty.leaderSkills);
assert.equal(player(declined, f.moritani).spice, player(empty, f.moritani).spice);
assert.equal(player(declined, f.opponent).leaders.find(l => l.id === f.target)!.dead, false);
custody(advance(declined, g => !g.battle && clean(g)));
}
});
void test('five actual original battles open native own revival; one real printed fee supplies private optional replacement without staged deaths or clocks', () => {
const f = createDiscoveryNativeE3SkillsRevivalFixture();
const assignment = f.beforeDeaths.leaderSkills!.assignments.find(a => a.owner === f.moritani)!;
assert.equal(new Set(f.deaths.map(d => d.leader)).size, 5);
assert.equal(new Set(f.deaths.map(d => d.winnerLeader)).size, 5);
assert.equal(new Set(f.deaths.map(d => d.territory)).size, 5);
for (const [index, death] of f.deaths.entries()) {
assert.equal(player(death.after, f.moritani).leaders.filter(l => l.dead).length, index + 1);
assert.equal(player(death.after, f.moritani).leaders.find(l => l.id === death.leader)!.deaths,
player(death.before, f.moritani).leaders.find(l => l.id === death.leader)!.deaths + 1);
assert.equal(death.after.lastBattleContext?.winner, f.opponent);
assert.equal(player(death.after, f.opponent).hand.filter(c => c.id === f.weapon).length, 1);
assert.equal(death.after.discard.some(c => c.id === f.weapon), false);
assert.equal(player(death.after, f.moritani).tanks, player(death.before, f.moritani).tanks + 1);
assert.equal(player(death.after, f.opponent).tanks, player(death.before, f.opponent).tanks);
custody(death.after);
}
assert.equal(f.revivalWindow.leaderSkills!.assignments.some(a => a.owner === f.moritani), false);
assert.equal(f.revivalWindow.leaderSkills!.deck.filter(c => c === assignment.skill).length, 1);
const printed = player(f.revivalWindow, f.moritani).leaders.find(l => l.id === f.leader)!.strength;
assert.equal(player(f.offered, f.moritani).spice, player(f.revivalWindow, f.moritani).spice - printed);
assert.equal(player(f.offered, f.moritani).leaders.find(l => l.id === f.leader)!.dead, false);
assert.equal(player(f.offered, f.moritani).leaders.filter(l => l.dead).length, 4);
const event = f.offered.leaderSkills!.offers[f.moritani].event;
assert.deepEqual(viewGame(f.offered, f.moritani).leaderSkills!.offer!.cards, []);
assert.equal(viewGame(f.offered, f.opponent).leaderSkills!.offer, null);
const declined = applyAction(structuredClone(f.offered), f.moritani, { type: 'leaderSkill', event, mode: 'decline' });
assert.equal(declined.leaderSkills!.offers[f.moritani], undefined);
assert.equal(declined.leaderSkills!.assignments.some(a => a.owner === f.moritani), false);
assert.equal(player(declined, f.moritani).spice, player(f.offered, f.moritani).spice); custody(declined);
const drawn = applyAction(structuredClone(f.offered), f.moritani, { type: 'leaderSkill', event, mode: 'draw' });
const offer = viewGame(drawn, f.moritani).leaderSkills!.offer!;
assert.equal(offer.cards.length, 2); assert.equal(viewGame(drawn, f.opponent).leaderSkills!.offer, null);
const own = viewGame(drawn, f.moritani).leaderSkills!;
const skill = offer.cards.find(c => !own.unavailableSkills?.[c]); assert.ok(skill);
const selected = applyAction(drawn, f.moritani, { type: 'leaderSkill', event, skill, leader: f.leader });
assert.deepEqual(selected.leaderSkills!.assignments.find(a => a.owner === f.moritani), { owner: f.moritani, skill, leader: f.leader });
assert.equal(selected.leaderSkills!.offers[f.moritani], undefined);
assert.equal(player(selected, f.moritani).spice, player(f.offered, f.moritani).spice);
assert.equal(selected.players.some(p => p.leaders.some(l => l.capturedBy || l.gholaBy)), false);
custody(selected);
});
+277
View File
@@ -0,0 +1,277 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, joinGame, newPlayer, viewGame, type Game,
} from '../game/engine';
import { gameDistance, gameTerritories, splitLocation, territory } from '../game/board';
import { type Card } from '../game/cards';
import { validateLeaderSkills } from '../game/leader-skills';
import { type LeaderSkillId } from '../game/leader-skill-cards';
import { initializeAdvancedNativeSkillsSetup } from './fixture-advanced-native-skills';
import { nextSkillsTechBattleStep, openSkillsTechBattle, type SkillsTechBattleStep } from './fixture-skills-tech-battle';
import { nativeTypedClearBoard, nativeTypedCustody, nativeTypedPlace, nativeTypedPlayer } from './fixture-discovery-native-typed';
import { putDiscovery } from './fixture-discovery';
export type DiscoveryNativeE1SkillsOptions = {
/** Original authenticated lobby or original undealt skill setup. Never convert a played save. */
initial?: Game;
advanced?: boolean;
tech?: boolean;
strongholds?: boolean;
skill?: LeaderSkillId;
};
export type DiscoveryNativeE1SkillsStep = SkillsTechBattleStep;
export type DiscoveryNativeE1SkillsEntry = {
initial: Game;
offered: Game;
afterSetup: Game;
afterFirstStorm: Game;
beforeFirstMentat: Game;
firstMentatStep: DiscoveryNativeE1SkillsStep;
game: Game;
owner: string;
opponent: string;
guild: string;
fremen: string;
trainer: string;
skill: LeaderSkillId;
token: string;
source: string;
actions: DiscoveryNativeE1SkillsStep[];
staging: string[];
};
export type DiscoveryNativeE1SkillsBattle = DiscoveryNativeE1SkillsEntry & {
beforeBattle: Game;
plans: DiscoveryNativeE1SkillsStep[];
weapon: string;
defense: string;
losingCard: string;
losingLeader: string;
location: 'shrine:0';
replacementSource: string;
};
export const reloadDiscoveryNativeE1Skills = (game: Game): Game => JSON.parse(JSON.stringify(game));
export const discoveryNativeE1SkillsPlayer = nativeTypedPlayer;
export function assertDiscoveryNativeE1SkillsCustody(game: Game): void {
nativeTypedCustody(game);
validateLeaderSkills(game.leaderSkills!, game.players);
}
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
/** Original controls, declining optional entry/substitution/Face Dance. Human consumers stop before them. */
export function nextDiscoveryNativeE1SkillsStep(game: Game): DiscoveryNativeE1SkillsStep {
const d = game.decision;
if (d?.kind === 'discoveryEntry') return { actor: d.player,
action: { type: 'decision', event: d.event, accept: false } };
if (d?.kind === 'ixSubstitution') return { actor: d.player, action: { type: 'decision', decline: true } };
if (d?.kind === 'faceDance') return { actor: d.player, action: { type: 'decision', reveal: false } };
if (d?.kind === 'sukRescue') {
const maximum = Math.max(...d.options.map(o => o.normal + o.elite));
const choice = d.options.findIndex(o => o.normal + o.elite === maximum && o.kept?.kind === 'normal');
return { actor: d.player, action: { type: 'decision', event: d.event,
choice: choice >= 0 ? choice : d.options.findIndex(o => o.normal + o.elite === maximum) } };
}
if (d?.kind === 'battleLosses') return { actor: d.player, action: { type: 'decision',
choice: d.options.findIndex(o => o.elite === Math.max(...d.options.map(c => c.elite))) } };
return nextSkillsTechBattleStep(game);
}
export function stepDiscoveryNativeE1Skills(game: Game, step = nextDiscoveryNativeE1SkillsStep(game)): Game {
return applyAction(game, step.actor, step.action);
}
export function advanceDiscoveryNativeE1Skills(state: Game, until: (game: Game) => boolean,
actions?: DiscoveryNativeE1SkillsStep[]): Game {
let game = state;
for (let i = 0; i < 1800; i++) {
if (until(game)) return game;
assert.equal(game.status, 'playing', 'Original programme must retain a live game');
const step = nextDiscoveryNativeE1SkillsStep(game);
actions?.push(structuredClone(step));
game = stepDiscoveryNativeE1Skills(game, step);
}
throw Error('Original E1 Discovery/Skills programme did not reach its requested control');
}
export function phaseDiscoveryNativeE1Skills(state: Game, phase: number): Game {
const turn = state.turn;
return advanceDiscoveryNativeE1Skills(state, game => {
assert.equal(game.turn, turn, 'Phase continuation cannot invent a later turn');
return game.phase === phase && clean(game);
});
}
export function settleDiscoveryNativeE1Skills(state: Game): Game {
return advanceDiscoveryNativeE1Skills(state, game => clean(game) && !game.pendingShipment && !game.pendingTreacheryDiscard);
}
function held(game: Game, actor: string, predicate: (card: Card) => boolean): string {
const player = nativeTypedPlayer(game, actor);
const owned = player.hand.find(predicate);
if (owned) return owned.id;
const index = game.deck.findIndex(predicate);
assert.ok(index >= 0 && player.hand.length < 4, 'Canonical unplayed card and native hand capacity required');
const card = game.deck.splice(index, 1)[0]; player.hand.push(card);
return card.id;
}
function matchingDancer(game: Game, actor: string, leader: string): void {
const stock = nativeTypedPlayer(game, actor).faceDancers!;
if (stock.some(c => c.leader === leader && !c.revealed)) return;
const dancer = stock.find(c => !c.revealed); assert.ok(dancer);
const index = game.traitorReserve!.indexOf(leader);
if (index >= 0) game.traitorReserve![index] = dancer.leader;
else {
const donor = game.players.find(p => p.traitors.includes(leader)); assert.ok(donor);
donor.traitors[donor.traitors.indexOf(leader)] = dancer.leader;
}
dancer.leader = leader;
}
/** Real setup → first Storm/Tech → Collection inspect/reveal → first END Mentat claim
* → first free typed entry. Conserved unplayed lotteries/positions are labeled below.
* Explicit requested disciplines require their genuine offer; omitted discipline
* on a supplied original setup selects an actually available offered card. */
export function createDiscoveryNativeE1SkillsEntry(options: DiscoveryNativeE1SkillsOptions = {}): DiscoveryNativeE1SkillsEntry {
const requestedSkill = options.skill ?? 'suk-graduate';
let game = options.initial ? structuredClone(options.initial)
: createGame('DISCOVERYE1SKILLS', newPlayer('ix', 'Ixians', 'ixians'), options.advanced ?? false, ['ix']);
if (!options.initial) for (const [id, faction] of [['tl', 'tleilaxu'], ['gu', 'guild'], ['fr', 'fremen']] as const)
joinGame(game, newPlayer(id, faction, faction));
assert.ok(game.status === 'lobby' || game.status === 'setup');
if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced);
const seat = (faction: string) => {
const player = game.players.find(p => p.faction === faction); assert.ok(player, `Original ${faction} seat required`);
return player.id;
};
const owner = seat('ixians'), opponent = seat('tleilaxu'), guild = seat('guild'), fremen = seat('fremen');
if (game.status === 'setup') {
assert.ok(game.discoveryEnabled && game.leaderSkills && game.turn === 1 && game.phase === 0);
assert.ok(game.players.every(p => !p.hand.length && !p.traitors.length && !p.traitorChoices.length),
'Only the original undealt setup with unused Traitor selections may continue');
if (options.tech) assert.ok(game.techTokens);
if (options.strongholds) assert.ok(game.strongholdCards);
} else {
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;
}
const initial = structuredClone(game), actions: DiscoveryNativeE1SkillsStep[] = [];
game = initializeAdvancedNativeSkillsSetup({ initial: game, family: 'ixians', skillOwner: 'ixians',
requestedSkill, rules: game.advanced ? 'advanced' : 'basic' });
const offered = structuredClone(game);
for (let i = 0; game.status === 'setup' && i < 250; i++) {
let step: DiscoveryNativeE1SkillsStep;
if (game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0], offer = game.leaderSkills!.offers[actor];
const view = viewGame(game, actor).leaderSkills!;
const selected = actor === owner && !(initial.status === 'setup' && options.skill === undefined)
? requestedSkill : offer.cards.find(c => !view.unavailableSkills?.[c]);
const eligible = new Set(view.eligibleLeaders.map(l => l.id));
const leader = nativeTypedPlayer(game, actor).leaders.filter(l => eligible.has(l.id))
.sort((a, b) => b.strength - a.strength)[0];
assert.ok(selected && offer.cards.includes(selected) && !view.unavailableSkills?.[selected] && leader,
'The actual original offer and living eligible disc must supply this programme');
step = { actor, action: { type: 'leaderSkill', event: offer.event, skill: selected, leader: leader.id } };
} else step = nextDiscoveryNativeE1SkillsStep(game);
actions.push(structuredClone(step)); game = stepDiscoveryNativeE1Skills(game, step);
}
assert.equal(game.status, 'playing');
const afterSetup = structuredClone(game), assignment = game.leaderSkills!.assignments.find(a => a.owner === owner)!;
const trainer = assignment.leader, skill = assignment.skill;
const blows = game.spiceDeck.filter(c => 'territory' in c && !c.discovery).slice(0, 6);
assert.equal(blows.length, 6);
for (const [index, card] of blows.entries()) game.spiceDeck.splice(index, 0, game.spiceDeck.splice(game.spiceDeck.indexOf(card), 1)[0]);
game = advanceDiscoveryNativeE1Skills(game, g => g.phase === 5 && clean(g), actions);
const afterFirstStorm = structuredClone(game);
nativeTypedClearBoard(game); // Retains the actual native HMS six-counter garrison.
const token = putDiscovery(game, 'shrine'), source = `${token.territory}:${token.sector}`;
assert.notEqual(token.sector, game.storm);
nativeTypedPlace(game, owner, source, 2, 1);
if (game.strongholdCards) nativeTypedPlace(game, owner, `arrakeen:${territory('arrakeen').sectors[0]}`, 1);
game = advanceDiscoveryNativeE1Skills(game, g => g.phase === 7 && clean(g), actions);
if (viewGame(game, owner).discoveries!.canInspect.includes(token.id)) {
const step: DiscoveryNativeE1SkillsStep = { actor: owner, action: { type: 'discovery', token: token.id, reveal: false } };
actions.push(step); game = stepDiscoveryNativeE1Skills(game, step);
}
assert.ok(viewGame(game, owner).discoveries!.canReveal.includes(token.id));
const reveal: DiscoveryNativeE1SkillsStep = { actor: owner, action: { type: 'discovery', token: token.id, reveal: true } };
actions.push(reveal); game = stepDiscoveryNativeE1Skills(game, reveal);
game = advanceDiscoveryNativeE1Skills(game, g => g.phase === 8 && clean(g), actions);
let beforeFirstMentat: Game | undefined, firstMentatStep: DiscoveryNativeE1SkillsStep | undefined;
while (game.turn === 1) {
const before = structuredClone(game), step = nextDiscoveryNativeE1SkillsStep(game);
actions.push(structuredClone(step)); game = stepDiscoveryNativeE1Skills(game, step);
if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = step; }
}
assert.ok(beforeFirstMentat && firstMentatStep);
game = advanceDiscoveryNativeE1Skills(game, g => g.decision?.kind === 'discoveryEntry', actions);
assert.equal(game.decision?.player, owner);
assertDiscoveryNativeE1SkillsCustody(game);
return { initial, offered, afterSetup, afterFirstStorm, beforeFirstMentat, firstMentatStep,
game, owner, opponent, guild, fremen, trainer, skill, token: token.id, source, actions,
staging: ['Only the original first all14 shuffle is controlled; supplied original offers are unchanged.',
'Conserved first six unused ordinary Spice Blows moved within their original deck; no fake phase or wallet history.',
'Conserved original board → reserves except native HMS; two Suboids/one Cyborg placed at an original supply Shrine token.',
'Optional Arrakeen control is conserved before the real first END Mentat; its retained card is never granted directly.'] };
}
/** Human sequence: decline free entry; real turn-two Movement; physical nested
* armies/cards; choose Shrine battle; hide training; seal these original plans.
* The second original conflict keeps cleanup/reward/Face Dance in Battle. */
export function createDiscoveryNativeE1SkillsBattle(options: DiscoveryNativeE1SkillsOptions = {}): DiscoveryNativeE1SkillsBattle {
const entry = createDiscoveryNativeE1SkillsEntry(options);
assert.ok(entry.skill === 'suk-graduate' || entry.skill === 'planetologist',
'This battle consumer requires genuine Suk/Planetologist training; other actual offers retain the typed entry programme');
assert.ok(entry.game.decision?.kind === 'discoveryEntry');
let game = applyAction(entry.game, entry.owner, { type: 'decision', event: entry.game.decision.event, accept: false });
game = phaseDiscoveryNativeE1Skills(game, 5);
nativeTypedClearBoard(game);
for (const p of game.players) { game.deck.push(...p.hand); p.hand = []; }
const location = 'shrine:0';
nativeTypedPlace(game, entry.owner, location, 6, 2);
nativeTypedPlace(game, entry.opponent, location, 8);
const parent = gameTerritories(game).find(t => t.id === splitLocation(entry.source).territory)!;
const replacementSource = `${parent.id}:${parent.sectors.find(s => s !== game.storm)}`;
nativeTypedPlace(game, entry.opponent, replacementSource, 3);
const other = gameTerritories(game).find(t => t.type === 'sand' && t.id !== parent.id && !t.sectors.includes(game.storm));
assert.ok(other);
for (const actor of [entry.owner, entry.opponent]) nativeTypedPlace(game, actor, `${other.id}:${other.sectors[0]}`, 1);
matchingDancer(game, entry.opponent, entry.trainer);
const losingLeader = nativeTypedPlayer(game, entry.opponent).leaders.filter(l => !l.dead &&
!game.leaderSkills!.assignments.some(a => a.leader === l.id)).sort((a, b) => a.strength - b.strength)[0];
assert.ok(losingLeader);
const weapon = held(game, entry.owner, c => entry.skill === 'planetologist' ? c.id === 'ix-thumper' : c.kind === 'projectile');
const defense = held(game, entry.owner, c => c.kind === 'shield');
const losingCard = held(game, entry.opponent, c => c.kind === 'worthless');
game = phaseDiscoveryNativeE1Skills(game, 6);
const beforeBattle = structuredClone(game);
game = openSkillsTechBattle(game, entry.owner, entry.opponent, 'shrine');
assertDiscoveryNativeE1SkillsCustody(game);
const plans: DiscoveryNativeE1SkillsStep[] = [
{ actor: entry.owner, action: { type: 'battlePlan', leader: entry.trainer,
dial: game.advanced ? 5 : 6, support: game.advanced ? 1 : 0, weapon, defense } },
{ actor: entry.opponent, action: { type: 'battlePlan', leader: losingLeader.id, dial: 0, support: 0, weapon: losingCard } },
];
return { ...entry, game, beforeBattle, plans, weapon, defense, losingCard, losingLeader: losingLeader.id,
location, replacementSource, staging: [...entry.staging,
'Original turn-two Movement: conserve six Suboids/two Cyborgs at Shrine, eight opposing counters, three replacement sources and a real second conflict.',
'Canonical unplayed Treachery and private matching Face Dancer identities exchanged within original custody; not a natural lottery claim.'] };
}
export function revealDiscoveryNativeE1SkillsBattle(fixture: DiscoveryNativeE1SkillsBattle): Game {
let game = reloadDiscoveryNativeE1Skills(fixture.game);
for (const step of fixture.plans) {
game = stepDiscoveryNativeE1Skills(game, step);
if (!game.battle?.revealed) game = settleDiscoveryNativeE1Skills(game);
}
assert.ok(game.battle?.revealed);
return game;
}
export function finishDiscoveryNativeE1SkillsBattle(state: Game,
stop: 'rescue' | 'substitution' | 'cards' | 'faceDance' | 'complete' = 'complete'): Game {
const kinds = { rescue: 'sukRescue', substitution: 'ixSubstitution', cards: 'battleCards', faceDance: 'faceDance', complete: '' };
return advanceDiscoveryNativeE1Skills(state, game => game.decision?.kind === kinds[stop] ||
!game.battle && clean(game) && !game.pendingTreacheryDiscard);
}
export function movementDiscoveryNativeE1Skills(state: Game, owner: string): Game {
return advanceDiscoveryNativeE1Skills(phaseDiscoveryNativeE1Skills(state, 5), game => game.active === owner && clean(game));
}
export function discoveryNativeE1PlanetologistRoute(game: Game): { source: string; distance: 2 | 3 } {
const source = gameTerritories(game).filter(t => t.type === 'sand').flatMap(t => t.sectors.map(s => `${t.id}:${s}`))
.find(key => splitLocation(key).sector !== game.storm && gameDistance(game, key, 'shrine:0', k => splitLocation(k).sector === game.storm) === 3);
assert.ok(source, 'Original revealed Shrine must supply a storm-safe three-territory route');
return { source, distance: 3 };
}
+365
View File
@@ -0,0 +1,365 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, handLimit, initializeLeaderSkillsGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game, type Player,
} from '../game/engine';
import { TERRITORIES } from '../game/board';
import type { Card } from '../game/cards';
import { DISCOVERY_CARD_PLACEMENTS, DISCOVERY_TOKEN_BY_ID, type DiscoveryTokenFace } from '../game/discoveries';
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
import { nextSkillsTechBattleStep, openSkillsTechBattle, finishSkillsTechBattle } from './fixture-skills-tech-battle';
export type DiscoveryNativeE2SkillsStep = { actor: string; action: Action };
export type DiscoveryNativeE2SkillsOptions = {
/** Authenticated fresh lobby or its original undealt, unassigned setup. */
initial?: Game;
advanced?: boolean;
tech?: boolean;
strongholds?: boolean;
kind?: 'stash' | 'invoice' | 'marker';
marker?: 0 | 3 | 5;
reserveCap?: number;
/** Human native-mechanics path: retain native random shuffle and select only
* real available offers. Specific trained consumers require their actual skill. */
useActualOffers?: boolean;
};
export type DiscoveryNativeE2SkillsFixture = {
initial: Game;
setup: Game;
afterSetup: Game;
afterFirstStorm: Game;
game: Game;
actions: DiscoveryNativeE2SkillsStep[];
staging: string[];
kind: 'stash' | 'invoice' | 'marker';
richese: string;
choam: string;
guild: string;
observer: string;
collector: string;
token: string;
face: DiscoveryTokenFace;
parent: string;
parentSector: number;
trainers: { richese: string; choam: string };
green: string;
};
export type DiscoveryNativeE2SkillsCheckpoint = {
beforeFirstMentat: Game;
firstMentatStep: DiscoveryNativeE2SkillsStep;
afterFirstMentat: Game;
game: Game;
};
export type DiscoveryNativeE2MarkerBattle = {
beforeShipment: Game;
shipmentStep: DiscoveryNativeE2SkillsStep;
afterShipment: Game;
game: Game;
token: string;
value: 0 | 3 | 5;
materialized: number;
key: string;
ownerPlan: Action;
opponentPlan: Action;
};
export const discoveryNativeE2SkillsClean = (game: Game) => !game.response && !game.decision && !game.phaseOpening;
export function discoveryNativeE2SkillsPlayer(game: Game, actor: string): Player {
const player = game.players.find(p => p.id === actor);
assert.ok(player);
return player;
}
const greenId = (game: Game) => game.advanced ? 'richese-residual-poison' : 'richese-nullentropy-box';
/** Control only the original all14 shuffle, never an existing offer or later shuffle. */
function withOffers<T>(selected: LeaderSkillId[], operation: () => T): T {
const working = LEADER_SKILL_CARDS.map(card => card.id);
const remaining = working.filter(card => !selected.includes(card));
const desired = selected.flatMap(card => [card, remaining.shift()!]);
desired.push(...remaining);
const rolls: number[] = [];
for (let i = working.length - 1; i > 0; i--) {
const j = working.indexOf(desired[i]);
assert.ok(j >= 0 && j <= i);
rolls.push(Math.floor((j + 0.5) * 0x100000000 / (i + 1)));
[working[i], working[j]] = [working[j], working[i]];
}
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const original = crypto.getRandomValues.bind(crypto);
let cursor = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (cursor < rolls.length) {
assert.ok(array instanceof Uint32Array && array.length === 1);
array[0] = rolls[cursor++];
} else Reflect.apply(original, crypto, [array]);
return array;
};
try { return operation(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
function requestedSkill(faction: string): LeaderSkillId | undefined {
return faction === 'richese' ? 'planetologist' : faction === 'choam' ? 'smuggler' : faction === 'emperor' ? 'bureaucrat' : undefined;
}
/** Native no-call/no-spend continuation; acquire the first actual green cache lot.
* Later unbid lots are removed normally, not counted as an exhausted-cache success. */
export function nextDiscoveryNativeE2SkillsStep(game: Game, useActualOffers = false): DiscoveryNativeE2SkillsStep {
if (game.status === 'setup' && game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0];
const offer = game.leaderSkills!.offers[actor], view = viewGame(game, actor).leaderSkills!;
const player = discoveryNativeE2SkillsPlayer(game, actor);
const requested = requestedSkill(player.faction);
const skill = useActualOffers
? offer.cards.find(card => card === requested && !view.unavailableSkills?.[card]) ?? offer.cards.find(card => !view.unavailableSkills?.[card])
: requested ?? offer.cards.find(card => !view.unavailableSkills?.[card]);
const eligible = new Set(view.eligibleLeaders.map(leader => leader.id));
const leader = player.leaders.filter(l => eligible.has(l.id)).sort((a, b) => b.strength - a.strength)[0];
assert.ok(skill && offer.cards.includes(skill) && !view.unavailableSkills?.[skill] && leader,
'The original offer must contain the requested native consumer skill; never replace an existing offer.');
return { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: leader.id } };
}
if (!game.response && !game.phaseOpening && game.decision) {
const d = game.decision, actor = d.player, event = game.richeseBidding?.event;
if (d.kind === 'richeseBlackMarket') return { actor, action: { type: 'decision', event, decline: true } };
if (d.kind === 'richeseDeclaration') return { actor, action: { type: 'decision', event, position: 'first' } };
if (d.kind === 'richeseCache') {
const card = game.richeseCache!.find(c => c.id === greenId(game)) ?? game.richeseCache![0];
assert.ok(card, 'The original cache must still have a physical lot.');
return { actor, action: { type: 'decision', event, card: card.id, method: 'onceAround', direction: 'counterclockwise' } };
}
if (d.kind === 'richeseUnbid') return { actor, action: { type: 'decision', event, keep: game.richeseAuction?.cardId === greenId(game) } };
if (d.kind === 'discoveryEntry') return { actor, action: { type: 'decision', event: d.event, accept: false } };
}
const lot = game.richeseAuction;
if (discoveryNativeE2SkillsClean(game) && lot && !lot.outcome) {
const actor = lot.method === 'silent' ? lot.order.find(id => lot.eligible.includes(id) && !Object.hasOwn(lot.sealed, id)) : lot.active;
assert.ok(actor);
return { actor, action: { type: 'richeseBid', event: lot.event, amount: lot.method === 'silent' ? 0 : null } };
}
return nextSkillsTechBattleStep(game);
}
export function stepDiscoveryNativeE2Skills(game: Game, step: DiscoveryNativeE2SkillsStep, actions?: DiscoveryNativeE2SkillsStep[]): Game {
actions?.push(structuredClone(step));
return applyAction(game, step.actor, step.action);
}
export function advanceDiscoveryNativeE2Skills(game: Game, until: (state: Game) => boolean, actions?: DiscoveryNativeE2SkillsStep[]): Game {
for (let n = 0; n < 1800; n++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
game = stepDiscoveryNativeE2Skills(game, nextDiscoveryNativeE2SkillsStep(game), actions);
}
throw Error('Original E2 Discovery/Skills policy did not reach its requested window.');
}
export function completeDiscoveryNativeE2SkillsSetup(game: Game, useActualOffers = false, actions?: DiscoveryNativeE2SkillsStep[]): Game {
for (let n = 0; game.status === 'setup' && n < 200; n++)
game = stepDiscoveryNativeE2Skills(game, nextDiscoveryNativeE2SkillsStep(game, useActualOffers), actions);
assert.equal(game.status, 'playing');
return game;
}
export function settleDiscoveryNativeE2Skills(game: Game, actions?: DiscoveryNativeE2SkillsStep[]): Game {
return advanceDiscoveryNativeE2Skills(game, state => discoveryNativeE2SkillsClean(state) &&
!state.pendingShipment && !state.pendingTreacheryDiscard && !state.pendingChoamWorthless, actions);
}
export function holdDiscoveryNativeE2SkillsCard(game: Game, actor: string, matches: (card: Card) => boolean, staging: string[] = []): Card {
const p = discoveryNativeE2SkillsPlayer(game, actor), held = p.hand.find(matches);
if (held) return held;
assert.ok(p.hand.length < handLimit(p));
const at = game.deck.findIndex(matches);
let card: Card;
if (at >= 0) card = game.deck.splice(at, 1)[0];
else {
const donor = game.players.find(other => other.id !== actor && other.hand.some(matches));
if (donor) {
card = donor.hand.splice(donor.hand.findIndex(matches), 1)[0];
staging.push(`Conserved original ${card.id}: ${donor.faction} held position to ${p.faction}; no earned sale or payment claimed.`);
} else {
const discarded = game.discard.findIndex(matches);
assert.ok(discarded >= 0, 'The original physical ordinary card must exist in current deck, hand or discard custody.');
card = game.discard.splice(discarded, 1)[0];
staging.push(`Controlled conserved original ${card.id}: actual public discard position to ${p.faction}'s hand; this is not an earned draw or a natural card history.`);
}
}
p.hand.push(card);
if (at >= 0) staging.push(`Conserved original ${card.id}: undealt ordinary deck to ${p.faction} hand.`);
return card;
}
function withPlacement<T>(game: Game, face: DiscoveryTokenFace, operation: () => T): T {
const eligible = game.discoveries!.tokens.filter(token => token.status === 'supply' && token.type === DISCOVERY_TOKEN_BY_ID[face].type);
const index = eligible.findIndex(token => token.face === face);
assert.ok(index >= 0);
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const original = crypto.getRandomValues.bind(crypto);
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1) array[0] = Math.floor((index + 0.5) * 0x100000000 / eligible.length);
else Reflect.apply(original, crypto, [array]);
return array;
};
try { return operation(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** Human path: original ready lobby → all14 choices → Traitor choices → first
* Storm → Discovery blow → zero-bid green cache acquisition → collector ships
* one real force → Collection. Stops BEFORE inspect/reveal. No wallets, phases,
* receipts, offers, Tech owners or paid marker histories are assigned. */
export function createDiscoveryNativeE2SkillsFixture(options: DiscoveryNativeE2SkillsOptions = {}): DiscoveryNativeE2SkillsFixture {
const kind = options.kind ?? 'marker';
let game = options.initial ? structuredClone(options.initial)
: createGame('DISCOVERYNATIVEE2SKILLS', newPlayer('richese', 'Richese', 'richese'), options.advanced ?? false, ['choam']);
if (!options.initial) for (const faction of ['choam', 'emperor', 'guild'] as const)
joinGame(game, newPlayer(faction, faction, faction));
assert.deepEqual(game.expansions, ['choam']);
if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced);
assert.ok(!game.homeworlds && !game.nexusCards && !game.spiceBankerIncomePreview && !game.mentatQuestionPreview);
assert.ok(game.status === 'lobby' || game.status === 'setup' && game.turn === 1 && game.phase === 0 &&
game.leaderSkills?.assignments.length === 0 && game.players.every(p =>
p.hand.length <= (p.faction === 'harkonnen' ? 2 : 1) && p.traitors.length === 0 && p.traitorChoices.length === 0),
'Only a fresh lobby or its original unassigned skill setup can enter; printed starting hands remain authoritative.');
const seat = (faction: string) => { const p = game.players.find(p => p.faction === faction); assert.ok(p); return p.id; };
const richese = seat('richese'), choam = seat('choam'), guild = seat('guild'), observer = seat('emperor');
const actions: DiscoveryNativeE2SkillsStep[] = [], staging: string[] = [];
if (game.status === 'lobby') {
if (options.tech && !game.techTokens) game = applyAction(game, game.host, { type: 'techTokens', enabled: true });
if (options.strongholds && !game.strongholdCards) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: true });
game.discoveryEnabled = true;
for (const p of game.players) if (!p.ready) game = applyAction(game, p.id, { type: 'ready' });
}
assert.equal(game.discoveryEnabled, true);
assert.ok(!game.strongholdCards || game.advanced);
const initial = structuredClone(game);
if (game.status === 'lobby') {
const passive: LeaderSkillId[] = ['warmaster', 'swordmaster-of-ginaz', 'killer-medic'];
const selected = game.players.map(p => requestedSkill(p.faction) ?? passive.shift()!);
game = options.useActualOffers
? initializeLeaderSkillsGameForAudit(game)
: withOffers(selected, () => initializeLeaderSkillsGameForAudit(game));
if (!options.useActualOffers) staging.push('Controlled only the original all14 offer shuffle; native subsequent deals and offers are authoritative.');
}
const setup = structuredClone(game);
game = completeDiscoveryNativeE2SkillsSetup(game, options.useActualOffers, actions);
const afterSetup = structuredClone(game);
const face: DiscoveryTokenFace = kind === 'stash' ? 'treachery-card-stash' : 'shrine';
const printed = DISCOVERY_TOKEN_BY_ID[face].type === 'hiereg' ? 'discovery-hagga-basin' : 'discovery-wind-pass-north';
const placement = DISCOVERY_CARD_PLACEMENTS[printed];
const index = game.spiceDeck.findIndex(card => 'territory' in card && card.discovery === printed);
assert.ok(index >= 0);
game.spiceDeck.unshift(game.spiceDeck.splice(index, 1)[0]);
for (let position = 1; position < 4; position++) {
const ordinary = game.spiceDeck.findIndex((card, i) => i >= position && 'territory' in card && !card.discovery);
assert.ok(ordinary >= position);
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(ordinary, 1)[0]);
}
staging.push('Reordered four original unplayed Spice Cards and selected the original physical Discovery placement lottery.');
game = advanceDiscoveryNativeE2Skills(game, g => g.phase === 1 && discoveryNativeE2SkillsClean(g), actions);
const afterFirstStorm = structuredClone(game);
game = withPlacement(game, face, () => advanceDiscoveryNativeE2Skills(game,
g => g.discoveries!.tokens.some(token => token.face === face && token.status === 'placed'), actions));
const collector = kind === 'stash' ? choam : guild;
game = advanceDiscoveryNativeE2Skills(game, g => g.phase === 5 && g.active === collector && discoveryNativeE2SkillsClean(g), actions);
assert.ok(discoveryNativeE2SkillsPlayer(game, richese).hand.some(card => card.id === greenId(game)),
'Acquire the original physical green card through Richese declaration, auction and unbid decision.');
game = stepDiscoveryNativeE2Skills(game, { actor: collector, action: {
type: 'ship', territory: placement.territory, sector: placement.sector, amount: 1, elite: 0, allyPayment: 0,
} }, actions);
game = settleDiscoveryNativeE2Skills(game, actions);
game = advanceDiscoveryNativeE2Skills(game, g => g.phase === 7 && discoveryNativeE2SkillsClean(g), actions);
const token = game.discoveries!.tokens.find(t => t.face === face)!;
assert.ok(viewGame(game, collector).discoveries!.canInspect.includes(token.id));
const trainers = { richese: game.leaderSkills!.assignments.find(a => a.owner === richese)!.leader,
choam: game.leaderSkills!.assignments.find(a => a.owner === choam)!.leader };
return { initial, setup, afterSetup, afterFirstStorm, game, actions, staging, kind, richese, choam, guild, observer,
collector, token: token.id, face, parent: placement.territory, parentSector: placement.sector, trainers, green: greenId(game) };
}
export function revealDiscoveryNativeE2Skills(fixture: DiscoveryNativeE2SkillsFixture): Game {
const game = stepDiscoveryNativeE2Skills(fixture.game, { actor: fixture.collector,
action: { type: 'discovery', token: fixture.token, reveal: false } }, fixture.actions);
return stepDiscoveryNativeE2Skills(game, { actor: fixture.collector,
action: { type: 'discovery', token: fixture.token, reveal: true } }, fixture.actions);
}
/** Exact next human path: inspect/reveal → original end-Mentat claims →
* next-turn free parent-to-Shrine entry → Storm/Bidding → requested Movement.
* Entitlements are never prefilled. */
export function discoveryNativeE2SkillsMovementWindow(fixture: DiscoveryNativeE2SkillsFixture, actor = fixture.richese): DiscoveryNativeE2SkillsCheckpoint {
assert.notEqual(fixture.kind, 'stash');
let game = revealDiscoveryNativeE2Skills(fixture);
game = advanceDiscoveryNativeE2Skills(game, g => g.turn === 1 && g.phase === 8 && discoveryNativeE2SkillsClean(g), fixture.actions);
let beforeFirstMentat = structuredClone(game), firstMentatStep = nextDiscoveryNativeE2SkillsStep(game);
while (game.turn === 1) {
beforeFirstMentat = structuredClone(game);
firstMentatStep = nextDiscoveryNativeE2SkillsStep(game);
game = stepDiscoveryNativeE2Skills(game, firstMentatStep, fixture.actions);
}
const afterFirstMentat = structuredClone(game);
game = advanceDiscoveryNativeE2Skills(game, g => g.decision?.kind === 'discoveryEntry' && g.decision.player === fixture.collector, fixture.actions);
const offer = viewGame(game, fixture.collector).discoveryEntry!;
const entry = discoveryEntryMoveAction(viewGame(game, fixture.collector), offer.sources);
assert.ok(entry);
game = stepDiscoveryNativeE2Skills(game, { actor: fixture.collector, action: entry }, fixture.actions);
game = settleDiscoveryNativeE2Skills(game, fixture.actions);
assert.equal(discoveryNativeE2SkillsPlayer(game, fixture.collector).forces[`${fixture.face}:0`], 1);
game = advanceDiscoveryNativeE2Skills(game, g => g.turn === 2 && g.phase === 5 && g.active === actor && discoveryNativeE2SkillsClean(g), fixture.actions);
return { beforeFirstMentat, firstMentatStep, afterFirstMentat, game };
}
export function discoveryNativeE2SkillsInvoiceAction(game: Game): { action: Action; key: string } {
const target = TERRITORIES.find(t => t.type === 'stronghold' && !t.sectors.includes(game.storm) &&
game.players.every(p => Object.entries(p.forces).every(([key, n]) => !n || !key.startsWith(`${t.id}:`))));
assert.ok(target);
return { action: { type: 'ship', territory: target.id, sector: target.sectors[0], amount: 6, elite: 0, allyPayment: 0, smuggler: true },
key: `${target.id}:${target.sectors[0]}` };
}
/** Marker-only: no companion, no ordinary Richese forces at the battle site.
* Optional reserve cap conserves counters in Polar Sink. The original green
* cache card is committed ONLY as Planetologist weapon, never its native power. */
export function prepareDiscoveryNativeE2SkillsMarkerBattle(fixture: DiscoveryNativeE2SkillsFixture,
options: Pick<DiscoveryNativeE2SkillsOptions, 'marker' | 'reserveCap'> = {}): DiscoveryNativeE2MarkerBattle {
assert.ok(fixture.game.leaderSkills!.assignments.some(a => a.owner === fixture.richese && a.skill === 'planetologist'),
'The specific green consumer requires a genuinely offered and selected Planetologist; the human marker window does not manufacture it.');
const window = discoveryNativeE2SkillsMovementWindow(fixture);
let game = window.game;
const player = discoveryNativeE2SkillsPlayer(game, fixture.richese), value = options.marker ?? 5;
if (options.reserveCap !== undefined) {
assert.ok(options.reserveCap >= 0 && options.reserveCap <= player.reserves);
const outside = player.reserves - options.reserveCap;
player.forces['polar_sink:0'] = (player.forces['polar_sink:0'] ?? 0) + outside;
player.reserves -= outside;
fixture.staging.push(`Conserved ${outside} original Richese reserve counters into Polar Sink outside the nested battle.`);
}
const physical = player.noField!.tokens.find(token => token.value === value)!;
const defense = holdDiscoveryNativeE2SkillsCard(game, fixture.richese, card => card.kind === 'shield', fixture.staging);
const beforeShipment = structuredClone(game);
const shipmentStep: DiscoveryNativeE2SkillsStep = { actor: fixture.richese, action: {
type: 'ship', territory: fixture.face, sector: 0, noField: physical.id, event: player.noFieldEvent, allyPayment: 0,
} };
game = stepDiscoveryNativeE2Skills(game, shipmentStep, fixture.actions);
game = settleDiscoveryNativeE2Skills(game, fixture.actions);
const afterShipment = structuredClone(game);
const enemy = discoveryNativeE2SkillsPlayer(game, fixture.guild);
assert.ok(enemy.reserves >= 7);
enemy.reserves -= 7;
enemy.forces[`${fixture.face}:0`] += 7;
fixture.staging.push('Conserved seven original Guild reserve counters into its actually entered Shrine, providing eight physical opposing battle counters.');
game = advanceDiscoveryNativeE2Skills(game, g => g.phase === 6 && discoveryNativeE2SkillsClean(g), fixture.actions);
game = openSkillsTechBattle(game, fixture.richese, fixture.guild, fixture.face, true);
const assigned = new Set(game.leaderSkills!.assignments.map(a => a.leader));
const trainer = discoveryNativeE2SkillsPlayer(game, fixture.richese).leaders.find(l => l.id === fixture.trainers.richese)!;
const opponent = discoveryNativeE2SkillsPlayer(game, fixture.guild).leaders.filter(l => !l.dead && !l.usedAt && !assigned.has(l.id) &&
l.strength <= trainer.strength + 1 && l.strength >= trainer.strength - 3).sort((a, b) => b.strength - a.strength)[0];
assert.ok(opponent);
const opposingDial = trainer.strength + 1 - opponent.strength;
assert.ok(opposingDial * (game.advanced ? 2 : 1) <= 8);
const materialized = Math.min(value, discoveryNativeE2SkillsPlayer(game, fixture.richese).reserves);
return { beforeShipment, shipmentStep, afterShipment, game, token: physical.id, value, materialized, key: `${fixture.face}:0`,
ownerPlan: { type: 'battlePlan', leader: fixture.trainers.richese, dial: 0, support: 0, weapon: fixture.green, defense: defense.id },
opponentPlan: { type: 'battlePlan', leader: opponent.id, dial: opposingDial, support: 0 } };
}
export function finishDiscoveryNativeE2SkillsBattle(game: Game, stop: 'cleanup' | 'complete' = 'complete'): Game {
return finishSkillsTechBattle(game, stop);
}
+390
View File
@@ -0,0 +1,390 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, handLimit, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, viewGame,
type Action, type Game, type Player,
} from '../game/engine';
import { territory } from '../game/board';
import type { Card } from '../game/cards';
import type { FactionId } from '../game/catalog';
import { DISCOVERY_CARD_PLACEMENTS, validateDiscoveryState } from '../game/discoveries';
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
import { validateLeaderSkills } from '../game/leader-skills';
import { nextMoritaniSkillsModulesStep, moritaniSkillsModulesPolicy } from './fixture-moritani-skills-modules';
import { holdMoritaniStrongholdsTraitor } from './fixture-moritani-strongholds';
export type DiscoveryNativeE3SkillsStep = { actor: string; action: Action };
export type DiscoveryNativeE3SkillsOptions = {
/** Authenticated fresh lobby or its ORIGINAL undealt setup, never a played save. */
initial?: Game;
rules?: 'basic' | 'advanced';
tech?: boolean;
strongholds?: boolean;
normalCall?: boolean;
battle?: 'nested' | 'tueks_sietch';
};
export type DiscoveryNativeE3SkillsFixture = {
initial: Game; setup: Game; afterSetup: Game; afterFirstStorm: Game;
beforeReveal: Game; afterReveal: Game;
beforeFirstMentat: Game; firstMentatStep: DiscoveryNativeE3SkillsStep; afterFirstMentat: Game;
entry: { before: Game; step: DiscoveryNativeE3SkillsStep; after: Game };
shipment: { before: Game; step: DiscoveryNativeE3SkillsStep; after: Game };
beforeBattle: Game; battleStep: DiscoveryNativeE3SkillsStep; game: Game; revealed: Game; pending: Game;
moritani: string; opponent: string; target: string; moritaniLeader: string; opponentLeader: string;
token: string; source: string; location: string; battleTerritory: string;
moritaniCards: string[]; opponentCards: string[]; plans: DiscoveryNativeE3SkillsStep[];
actions: DiscoveryNativeE3SkillsStep[]; staging: string[];
};
export function discoveryNativeE3SkillsPlayer(game: Game, actor: string): Player {
const player = game.players.find(p => p.id === actor); assert.ok(player); return player;
}
export const discoveryNativeE3SkillsClean = (game: Game) =>
!game.response && !game.decision && !game.phaseOpening && !game.pendingTreacheryDiscard;
export const discoveryNativeE3SkillsPolicy = moritaniSkillsModulesPolicy;
export function nextDiscoveryNativeE3SkillsStep(game: Game): DiscoveryNativeE3SkillsStep | null {
if (!game.response && !game.phaseOpening && game.decision?.kind === 'discoveryEntry')
return { actor: game.decision.player, action: { type: 'decision', event: game.decision.event, accept: false } };
if (!game.response && !game.phaseOpening && game.decision?.kind === 'sukRescue') {
const d = game.decision, maximum = Math.max(...d.options.map(o => o.normal + o.elite));
const choice = d.options.findIndex(o => o.normal + o.elite === maximum && o.kept?.kind === 'normal');
return { actor: d.player, action: { type: 'decision', event: d.event,
choice: choice >= 0 ? choice : d.options.findIndex(o => o.normal + o.elite === maximum) } };
}
return nextMoritaniSkillsModulesStep(game);
}
function act(game: Game, step: DiscoveryNativeE3SkillsStep, actions?: DiscoveryNativeE3SkillsStep[]): Game {
actions?.push(structuredClone(step)); return applyAction(game, step.actor, step.action);
}
export function advanceDiscoveryNativeE3Skills(state: Game, until: (g: Game) => boolean,
actions?: DiscoveryNativeE3SkillsStep[]): Game {
let game = state;
for (let n = 0; n < 2200; n++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const next = nextDiscoveryNativeE3SkillsStep(game); assert.ok(next, 'Original sealed plans need their actual owners.');
if (game.turn === 1 && next.action.type === 'stormDial') next.action.amount = 0;
game = act(game, next, actions);
}
throw Error('Original Discovery/Moritani skill chronology did not reach its boundary.');
}
/** Explicit zero-support original plans finish the deliberately separate conflict
* before later-turn proofs. Immediate primary-battle receipts remain separate. */
export function finishDiscoveryNativeE3SkillsBattles(state: Game,
actions?: DiscoveryNativeE3SkillsStep[]): Game {
let game = state;
for (let n = 0; n < 300; n++) {
if (game.phase !== 6) return game;
const next = nextDiscoveryNativeE3SkillsStep(game);
if (next) { game = act(game, next, actions); continue; }
assert.ok(game.battle && !game.battle.revealed);
for (const actor of [game.battle.attacker, game.battle.defender]) {
if (game.battle!.plans[actor]) continue;
const owner = discoveryNativeE3SkillsPlayer(game, actor);
const living = owner.leaders.filter(leader => !leader.dead &&
!game.leaderSkills!.assignments.some(assignment => assignment.leader === leader.id) &&
(!leader.usedAt || leader.usedAt === game.battle!.territory));
living.sort((a, b) => owner.faction === 'moritani' ? b.strength - a.strength : a.strength - b.strength);
assert.ok(living[0]);
game = act(game, { actor, action: { type: 'battlePlan', leader: living[0].id,
dial: 0, support: 0, weapon: null, defense: null } }, actions);
}
}
throw Error('Original remaining conflict did not complete.');
}
function prepareDiscoveryNativeE3HiddenPlans(state: Game, actions: DiscoveryNativeE3SkillsStep[]): Game {
let game = state;
for (let n = 0; n < 160; n++) {
const next = nextDiscoveryNativeE3SkillsStep(game);
if (!next) { assert.ok(game.battle && !game.battle.revealed); return game; }
if (next.action.type === 'leaderSkillVisibility') next.action.hide = true;
game = act(game, next, actions);
}
throw Error('Original trained-disc battle plans did not open.');
}
function entropy<T>(operation: () => T, choose: (index: number) => number): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const original = crypto.getRandomValues.bind(crypto); let index = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
const value = choose(index++);
if (value < 0) return Reflect.apply(original, crypto, [array]) as V;
assert.ok(array instanceof Uint32Array && array.length === 1); array[0] = value; return array;
};
try { return operation(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** Physical first all14 shuffle only. No injected/replaced offers or hidden hands. */
function initializeOffers(game: Game): Game {
const current = LEADER_SKILL_CARDS.map(c => c.id) as LeaderSkillId[];
const skills = game.players.map(p => p.faction === 'guild' ? 'suk-graduate' :
p.faction === 'moritani' ? 'warmaster' :
(['killer-medic', 'prana-bindu-adept', 'swordmaster-of-ginaz', 'master-of-assassins'] as LeaderSkillId[])[
game.players.filter(q => !['guild', 'moritani'].includes(q.faction)).findIndex(q => q.id === p.id)]);
const desired = Array<LeaderSkillId>(current.length); skills.forEach((skill, n) => { desired[n * 2] = skill; });
const rest = current.filter(c => !skills.includes(c));
for (let n = 0; n < desired.length; n++) if (!desired[n]) desired[n] = rest.shift()!;
let index = current.length - 1;
return entropy(() => initializeLeaderSkillsGameForAudit(game), () => {
if (index < 1) return -1;
const swap = current.indexOf(desired[index]); assert.ok(swap <= index);
const scalar = Math.floor((swap + 0.5) / (index + 1) * 0x100000000);
[current[index], current[swap]] = [current[swap], current[index]]; index--; return scalar;
});
}
/** Ready/initialize this original lobby, or preserve an original undealt setup. */
export function initializeDiscoveryNativeE3Skills(options: DiscoveryNativeE3SkillsOptions = {}): Game {
const advanced = options.rules ? options.rules === 'advanced' : options.initial?.advanced ?? true;
const tech = options.tech ?? !!options.initial?.techTokens;
const strongholds = options.strongholds ?? !!options.initial?.strongholdCards;
let game = options.initial ? structuredClone(options.initial)
: createGame('DISCOVERYNATIVEE3SKILLS', newPlayer('moritani', 'Moritani', 'moritani'), advanced, ['ecaz']);
if (!options.initial) for (const faction of ['guild', 'emperor'] as FactionId[]) joinGame(game, newPlayer(faction, faction, faction));
assert.equal(game.advanced, advanced); assert.deepEqual(game.expansions, ['ecaz']);
assert.ok(game.players.length >= (tech ? 3 : 2) && game.players.length <= 6);
assert.equal(game.players.filter(p => p.faction === 'moritani').length, 1);
assert.equal(game.players.filter(p => p.faction === 'guild').length, 1);
assert.equal(new Set(game.players.map(p => p.faction)).size, game.players.length);
assert.ok(game.players.every(p => ['moritani', 'guild', 'atreides', 'emperor', 'fremen', 'beneGesserit',
...(advanced ? [] : ['harkonnen'])].includes(p.faction)));
assert.ok(!strongholds || advanced);
assert.ok(!game.nexusCards && !game.homeworlds && !game.ecazTreachery && !game.spiceBankerIncomePreview && !game.mentatQuestionPreview);
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;
for (const p of game.players) if (!p.ready) game = applyAction(game, p.id, { type: 'ready' });
return initializeOffers(game);
}
assert.ok(game.status === 'setup' && game.turn === 1 && game.phase === 0 &&
game.leaderSkills?.assignments.length === 0 && game.players.every(p =>
p.hand.length <= (p.faction === 'harkonnen' ? 2 : 1) && p.traitors.length === 0 && p.traitorChoices.length === 0),
'Only an ORIGINAL unassigned native setup may be continued; printed starting hands are preserved.');
assert.ok(game.discoveryEnabled && game.discoveries && game.leaderSkills);
assert.equal(!!game.techTokens, tech); assert.equal(!!game.strongholdCards, strongholds);
return game;
}
export function completeDiscoveryNativeE3SkillsSetup(state: Game, actions?: DiscoveryNativeE3SkillsStep[]): Game {
let game = state;
for (let n = 0; game.status === 'setup' && n < 240; n++) {
if (game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0], offer = game.leaderSkills!.offers[actor];
const own = viewGame(game, actor).leaderSkills!, p = discoveryNativeE3SkillsPlayer(game, actor);
const requested = p.faction === 'guild' ? 'suk-graduate' : undefined;
const available = (c: LeaderSkillId) => !own.unavailableSkills?.[c];
const skill = requested && offer.cards.includes(requested) && available(requested) ? requested
: offer.cards.find(c => !['mentat', 'spice-banker', 'diplomat'].includes(c) && available(c))
?? offer.cards.find(available);
const leader = p.faction === 'guild' ? own.eligibleLeaders.find(l => l.name === 'Master Bewt') : own.eligibleLeaders[0];
assert.ok(skill && offer.cards.includes(skill) && !own.unavailableSkills?.[skill] && leader,
'Actual original offers must supply this consumer; never substitute an injected offer.');
game = act(game, { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: leader.id } }, actions);
} else { const next = nextDiscoveryNativeE3SkillsStep(game); assert.ok(next); game = act(game, next, actions); }
}
assert.equal(game.status, 'playing'); return game;
}
function reserveExcept(game: Game, keep: string[]): void {
for (const p of game.players) for (const [key, amount] of Object.entries(p.forces)) {
if (keep.includes(key)) continue;
p.reserves += amount; delete p.forces[key];
if (p.elites) { p.elites.reserves += p.elites.forces[key] ?? 0; delete p.elites.forces[key]; }
if (p.advisors) delete p.advisors[key.split(':')[0]];
}
}
function place(game: Game, actor: string, location: string, amount: number): void {
const p = discoveryNativeE3SkillsPlayer(game, actor); assert.ok(p.reserves - (p.elites?.reserves ?? 0) >= amount);
p.reserves -= amount; p.forces[location] = (p.forces[location] ?? 0) + amount;
}
function held(game: Game, actor: string, matches: (c: Card) => boolean, staging: string[], excluded: string[] = []): string {
const p = discoveryNativeE3SkillsPlayer(game, actor), match = (c: Card) => matches(c) && !excluded.includes(c.id);
const owned = p.hand.find(match); if (owned) return owned.id;
assert.ok(p.hand.length < handLimit(p));
const index = game.deck.findIndex(match); assert.ok(index >= 0);
const card = game.deck.splice(index, 1)[0]; p.hand.push(card);
staging.push(`Conserved original Ecaz33 ${card.id}: remaining deck → ${actor}'s hand.`); return card.id;
}
export function revealDiscoveryNativeE3SkillsPlans(game: Game, plans: DiscoveryNativeE3SkillsStep[],
actions?: DiscoveryNativeE3SkillsStep[]): Game {
for (const plan of plans) {
game = act(game, plan, actions);
while (game.response || game.phaseOpening || game.decision?.kind === 'fullPlanOffer') {
const next = nextDiscoveryNativeE3SkillsStep(game); assert.ok(next); game = act(game, next, actions);
}
}
assert.ok(game.battle?.revealed); return game;
}
export function resolveDiscoveryNativeE3SkillsBattle(game: Game, moritani: string, normalCall = false,
actions?: DiscoveryNativeE3SkillsStep[]): Game {
for (let n = 0; n < 180; n++) {
if (game.decision?.kind === 'moritaniAssassinate' || !game.battle && discoveryNativeE3SkillsClean(game)) return game;
const next = nextDiscoveryNativeE3SkillsStep(game); assert.ok(next);
if (next.action.type === 'traitorCall') next.action.call = normalCall && next.actor === moritani;
game = act(game, next, actions);
}
throw Error('Original nested battle suffix did not settle.');
}
/** Human sequence: native training/Traitors/start → actual printed blow → Guild
* shipment/inspect/reveal → first END Mentat claims → original free entry →
* Weather Control zero → paid Moritani nested shipment → Battle plans → Suk →
* after-loss assassination → winner cards/Tech → actual Mentat replacement.
* Explicit physical lottery/card/board staging below is not a natural history. */
export function createDiscoveryNativeE3SkillsFixture(options: DiscoveryNativeE3SkillsOptions = {}): DiscoveryNativeE3SkillsFixture {
let game = initializeDiscoveryNativeE3Skills(options);
const initial = options.initial ? structuredClone(options.initial) : structuredClone(game);
const setup = structuredClone(game), actions: DiscoveryNativeE3SkillsStep[] = [], staging: string[] = [];
game = completeDiscoveryNativeE3SkillsSetup(game, actions); const afterSetup = structuredClone(game);
const moritani = game.players.find(p => p.faction === 'moritani')!.id, opponent = game.players.find(p => p.faction === 'guild')!.id;
const printed = 'discovery-hagga-basin', placement = DISCOVERY_CARD_PLACEMENTS[printed];
const source = `${placement.territory}:${placement.sector}`;
const index = game.spiceDeck.findIndex(c => 'territory' in c && c.discovery === printed); assert.ok(index >= 0);
game.spiceDeck.unshift(game.spiceDeck.splice(index, 1)[0]);
for (let position = 1; position < 14; position++) {
const ordinary = game.spiceDeck.findIndex((c, i) => i >= position && 'territory' in c && !c.discovery);
assert.ok(ordinary >= position); game.spiceDeck.splice(position, 0, game.spiceDeck.splice(ordinary, 1)[0]);
}
reserveExcept(game, []);
staging.push('Conserved native board counters through own reserves; reordered existing unplayed printed Discovery and ordinary Spice Cards only.');
game = advanceDiscoveryNativeE3Skills(game, g => g.phase === 1 && discoveryNativeE3SkillsClean(g), actions);
const afterFirstStorm = structuredClone(game);
const tokens = game.discoveries!.tokens.filter(t => t.status === 'supply' && t.type === 'hiereg');
const selected = tokens.findIndex(t => t.face === 'cistern'); assert.ok(selected >= 0);
game = entropy(() => advanceDiscoveryNativeE3Skills(game,
g => g.discoveries!.tokens.some(t => t.face === 'cistern' && t.status === 'placed'), actions),
() => Math.floor((selected + 0.5) / tokens.length * 0x100000000));
game = advanceDiscoveryNativeE3Skills(game, g => g.phase === 5 && g.active === opponent && discoveryNativeE3SkillsClean(g), actions);
game = act(game, { actor: opponent, action: { type: 'ship', territory: placement.territory, sector: placement.sector, amount: 3 } }, actions);
game = advanceDiscoveryNativeE3Skills(game, g => g.phase === 7 && discoveryNativeE3SkillsClean(g), actions);
const token = game.discoveries!.tokens.find(t => t.face === 'cistern')!.id, beforeReveal = structuredClone(game);
game = act(game, { actor: opponent, action: { type: 'discovery', token, reveal: false } }, actions);
game = act(game, { actor: opponent, action: { type: 'discovery', token, reveal: true } }, actions);
const afterReveal = structuredClone(game);
game = advanceDiscoveryNativeE3Skills(game, g => g.phase === 8 && discoveryNativeE3SkillsClean(g), actions);
reserveExcept(game, [source]);
place(game, moritani, `tueks_sietch:${territory('tueks_sietch').sectors[0]}`, 1);
place(game, opponent, `carthag:${territory('carthag').sectors[0]}`, 1);
staging.push('Before actual first END Mentat: one conserved claim counter per combatant; preserve three real Guild Discovery-source arrivals.');
let beforeFirstMentat: Game | undefined, firstMentatStep: DiscoveryNativeE3SkillsStep | undefined;
while (game.turn === 1) {
const next = nextDiscoveryNativeE3SkillsStep(game); assert.ok(next);
const before = structuredClone(game); game = act(game, next, actions);
if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = next; }
}
assert.ok(beforeFirstMentat && firstMentatStep); const afterFirstMentat = structuredClone(game);
game = advanceDiscoveryNativeE3Skills(game, g => g.decision?.kind === 'discoveryEntry' && g.decision.player === opponent, actions);
const entryBefore = structuredClone(game), own = viewGame(game, opponent);
const entryAction = discoveryEntryMoveAction(own, own.discoveryEntry!.sources); assert.ok(entryAction);
const entryStep = { actor: opponent, action: entryAction }; game = act(game, entryStep, actions);
const entry = { before: entryBefore, step: entryStep, after: structuredClone(game) };
game = advanceDiscoveryNativeE3Skills(game, g => g.phase === 0 && discoveryNativeE3SkillsClean(g), actions);
for (const p of game.players) { game.deck.push(...p.hand); p.hand = []; }
staging.push('Returned original physical hands to their same Ecaz33 deck before source-local card transfers; no face manufacture.');
const weather = held(game, moritani, c => c.effect === 'weather', staging);
game = act(game, { actor: moritani, action: { type: 'card', card: weather, amount: 0 } }, actions);
game = advanceDiscoveryNativeE3Skills(game, g => g.phase === 5 && g.active === moritani && discoveryNativeE3SkillsClean(g), actions);
const shipmentBefore = structuredClone(game), shipmentStep: DiscoveryNativeE3SkillsStep = {
actor: moritani, action: { type: 'ship', territory: 'cistern', sector: 0, amount: 1 } };
game = act(game, shipmentStep, actions);
game = advanceDiscoveryNativeE3Skills(game, g => !g.pendingShipment && discoveryNativeE3SkillsClean(g), actions);
const shipment = { before: shipmentBefore, step: shipmentStep, after: structuredClone(game) };
const battleTerritory = options.battle === 'tueks_sietch' ? 'tueks_sietch' : 'cistern';
const location = `${battleTerritory}:${battleTerritory === 'cistern' ? 0 : territory(battleTerritory).sectors[0]}`;
reserveExcept(game, ['cistern:0']);
for (const actor of [moritani, opponent]) place(game, actor, location, 6 - (discoveryNativeE3SkillsPlayer(game, actor).forces[location] ?? 0));
staging.push(`Before actual movement completion: top up ${location} to six conserved ordinary counters per combatant; preserve original free/paid Cistern entry and earned card custody.`);
const secondTerritory = 'wind_pass';
const secondSector = territory(secondTerritory).sectors.find(sector => sector !== game.storm)!;
for (const actor of [moritani, opponent]) place(game, actor, `${secondTerritory}:${secondSector}`, 1);
staging.push('Conserved one ordinary counter per combatant at Wind Pass for a second real conflict; keep immediate battle receipts separate from later Collection.');
const moritaniCards = [held(game, moritani, c => c.kind === 'worthless', staging)];
moritaniCards.push(held(game, moritani, c => c.kind === 'worthless', staging, moritaniCards));
const opponentCards = [held(game, opponent, c => c.kind === 'worthless', staging)];
const trained = new Set(game.leaderSkills!.assignments.map(a => a.leader));
const ownLeader = discoveryNativeE3SkillsPlayer(game, moritani).leaders.filter(l => !l.dead && !trained.has(l.id)).sort((a, b) => a.strength - b.strength)[0];
const enemyLeader = discoveryNativeE3SkillsPlayer(game, opponent).leaders.filter(l => !l.dead && !trained.has(l.id)).sort((a, b) => b.strength - a.strength)[0];
const target = game.leaderSkills!.assignments.find(a => a.owner === opponent)!.leader;
assert.ok(ownLeader && enemyLeader && enemyLeader.id !== target);
holdMoritaniStrongholdsTraitor(game, moritani, options.normalCall ? enemyLeader.id : target, staging);
game = advanceDiscoveryNativeE3Skills(game, g => g.phase === 6 && discoveryNativeE3SkillsClean(g), actions);
const beforeBattle = structuredClone(game), actor = game.active!; assert.ok([moritani, opponent].includes(actor));
const battleStep: DiscoveryNativeE3SkillsStep = { actor, action: { type: 'chooseBattle', territory: battleTerritory, target: actor === moritani ? opponent : moritani } };
game = act(game, battleStep, actions);
game = advanceDiscoveryNativeE3Skills(game, g => !!g.battle && !g.battle.revealed && !nextDiscoveryNativeE3SkillsStep(g), actions);
const plans: DiscoveryNativeE3SkillsStep[] = [
{ actor: moritani, action: { type: 'battlePlan', leader: ownLeader.id, dial: 1, support: game.advanced ? 1 : 0, weapon: moritaniCards[0], defense: moritaniCards[1] } },
{ actor: opponent, action: { type: 'battlePlan', leader: enemyLeader.id, dial: 3, support: game.advanced ? 3 : 0, weapon: opponentCards[0] } },
];
const prepared = structuredClone(game), revealed = revealDiscoveryNativeE3SkillsPlans(game, plans, actions);
const pending = resolveDiscoveryNativeE3SkillsBattle(structuredClone(revealed), moritani, options.normalCall, actions);
validateLeaderSkills(pending.leaderSkills!, pending.players); validateDiscoveryState(pending.discoveries!);
return { initial, setup, afterSetup, afterFirstStorm, beforeReveal, afterReveal, beforeFirstMentat, firstMentatStep,
afterFirstMentat, entry, shipment, beforeBattle, battleStep, game: prepared, revealed, pending,
moritani, opponent, target, moritaniLeader: ownLeader.id, opponentLeader: enemyLeader.id,
token, source, location, battleTerritory, moritaniCards, opponentCards, plans, actions, staging };
}
export type DiscoveryNativeE3SkillsDeath = {
before: Game; revealed: Game; after: Game; territory: string; leader: string; winnerLeader: string;
battleStep: DiscoveryNativeE3SkillsStep; plans: DiscoveryNativeE3SkillsStep[];
};
export type DiscoveryNativeE3SkillsRevivalFixture = {
discovery: DiscoveryNativeE3SkillsFixture; beforeDeaths: Game; deaths: DiscoveryNativeE3SkillsDeath[];
revivalWindow: Game; revivalStep: DiscoveryNativeE3SkillsStep; offered: Game;
moritani: string; opponent: string; leader: string; weapon: string;
actions: DiscoveryNativeE3SkillsStep[]; staging: string[];
};
/** Five REAL battles in one original Battle phase kill each original Moritani
* disc once. Both sides use five distinct native discs; the same physical Guild
* poison is retained after each zero-dial win. No injected deaths, wallet,
* revival-cycle, phase or receipt. The next original Revival pays printed cost. */
export function createDiscoveryNativeE3SkillsRevivalFixture(): DiscoveryNativeE3SkillsRevivalFixture {
const discovery = createDiscoveryNativeE3SkillsFixture();
const { moritani, opponent } = discovery, actions = [...discovery.actions], staging = [...discovery.staging];
let game = structuredClone(discovery.pending);
const d = game.decision; assert.equal(d?.kind, 'moritaniAssassinate');
if (d?.kind !== 'moritaniAssassinate') throw Error('Expected actual original loss opportunity.');
game = act(game, { actor: moritani, action: { type: 'decision', event: d.event, decline: true } }, actions);
game = finishDiscoveryNativeE3SkillsBattles(game, actions);
game = advanceDiscoveryNativeE3Skills(game, g => g.turn === 3 && g.phase === 5 && discoveryNativeE3SkillsClean(g), actions);
reserveExcept(game, []);
for (const p of game.players) { game.deck.push(...p.hand); p.hand = []; }
const weapon = held(game, opponent, c => c.kind === 'poison', staging);
const territories = ['cielago_east', 'wind_pass', 'plastic_basin', 'pasty_mesa', 'false_wall_west'];
const moritaniLeaders = discoveryNativeE3SkillsPlayer(game, moritani).leaders.filter(l => !l.dead);
const guildLeaders = discoveryNativeE3SkillsPlayer(game, opponent).leaders.filter(l => !l.dead);
assert.equal(moritaniLeaders.length, 5); assert.equal(guildLeaders.length, 5);
for (const site of territories) {
const sector = territory(site).sectors.find(s => s !== game.storm); assert.notEqual(sector, undefined);
place(game, moritani, `${site}:${sector}`, 1); place(game, opponent, `${site}:${sector}`, 1);
}
staging.push('Before real turn-three Movement completes: conserved one ordinary counter per combatant at each of five original territories; one original Guild poison remains reusable through winner retention.');
game = advanceDiscoveryNativeE3Skills(game, g => g.phase === 6 && discoveryNativeE3SkillsClean(g), actions);
const beforeDeaths = structuredClone(game), deaths: DiscoveryNativeE3SkillsDeath[] = [];
for (const [index, site] of territories.entries()) {
const before = structuredClone(game), actor = game.active!; assert.ok([moritani, opponent].includes(actor));
const battleStep: DiscoveryNativeE3SkillsStep = { actor, action: { type: 'chooseBattle', territory: site,
target: actor === moritani ? opponent : moritani } };
game = act(game, battleStep, actions);
game = prepareDiscoveryNativeE3HiddenPlans(game, actions);
const plans: DiscoveryNativeE3SkillsStep[] = [
{ actor: moritani, action: { type: 'battlePlan', leader: moritaniLeaders[index].id, dial: 0, support: 0 } },
{ actor: opponent, action: { type: 'battlePlan', leader: guildLeaders[index].id, dial: 0, support: 0, weapon } },
];
game = revealDiscoveryNativeE3SkillsPlans(game, plans, actions); const revealed = structuredClone(game);
game = resolveDiscoveryNativeE3SkillsBattle(game, moritani, false, actions);
if (game.decision?.kind === 'moritaniAssassinate') game = act(game, { actor: moritani,
action: { type: 'decision', event: game.decision.event, decline: true } }, actions);
game = advanceDiscoveryNativeE3Skills(game, g => !g.battle && discoveryNativeE3SkillsClean(g), actions);
deaths.push({ before, revealed, after: structuredClone(game), territory: site,
leader: moritaniLeaders[index].id, winnerLeader: guildLeaders[index].id, battleStep, plans });
}
assert.ok(discoveryNativeE3SkillsPlayer(game, moritani).leaders.every(l => l.dead));
const assignment = beforeDeaths.leaderSkills!.assignments.find(a => a.owner === moritani); assert.ok(assignment);
game = advanceDiscoveryNativeE3Skills(game, g => g.turn === 4 && g.phase === 4 && discoveryNativeE3SkillsClean(g), actions);
const revivalWindow = structuredClone(game), revivalStep: DiscoveryNativeE3SkillsStep = {
actor: moritani, action: { type: 'reviveLeader', leader: assignment.leader } };
game = act(game, revivalStep, actions);
game = advanceDiscoveryNativeE3Skills(game, g => g.decision?.kind === 'leaderSkillRevival' && g.decision.player === moritani, actions);
return { discovery, beforeDeaths, deaths, revivalWindow, revivalStep, offered: game,
moritani, opponent, leader: assignment.leader, weapon, actions, staging };
}
+26 -8
View File
@@ -51,13 +51,19 @@ type Profile = 'base' | 'choam' | 'ecaz' | 'ecaz-treachery' | 'ecaz-occupy' | 'm
| 'e3-nexus-skills' | 'e3-nexus-skills-tech' | 'e3-nexus-skills-stronghold' | 'e3-nexus-skills-stronghold-tech' | 'homeworld-occupation' | 'e3-nexus-skills' | 'e3-nexus-skills-tech' | 'e3-nexus-skills-stronghold' | 'e3-nexus-skills-stronghold-tech' | 'homeworld-occupation'
| 'discovery' | 'discovery-tech' | 'discovery-skills' | 'discovery-skills-tech' | 'discovery' | 'discovery-tech' | 'discovery-skills' | 'discovery-skills-tech'
| 'discovery-stronghold' | 'discovery-stronghold-tech' | 'discovery-skills-stronghold' | 'discovery-skills-stronghold-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';
const discoveryStrongholdProfile = (profile: string) => profile === 'discovery-stronghold' || const discoveryStrongholdProfile = (profile: string) => profile === 'discovery-stronghold' ||
profile === 'discovery-stronghold-tech' || profile === 'discovery-skills-stronghold' || profile === 'discovery-stronghold-tech' || profile === 'discovery-skills-stronghold' ||
profile === 'discovery-skills-stronghold-tech'; profile === 'discovery-skills-stronghold-tech' || profile === 'native-discovery-skills-stronghold' ||
profile === 'native-discovery-skills-stronghold-tech';
const discoveryTechProfile = (profile: string) => profile === 'discovery-tech' || const discoveryTechProfile = (profile: string) => profile === 'discovery-tech' ||
profile === 'discovery-skills-tech' || profile === 'discovery-stronghold-tech' || profile === 'discovery-skills-tech' || profile === 'discovery-stronghold-tech' ||
profile === 'discovery-skills-stronghold-tech'; profile === 'discovery-skills-stronghold-tech' || profile === 'native-discovery-skills-tech' ||
profile === 'native-discovery-skills-stronghold-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';
type Rules = 'basic' | 'advanced'; type Rules = 'basic' | 'advanced';
type Scenario = { type Scenario = {
@@ -422,6 +428,14 @@ const DISCOVERY_STRONGHOLD_SCENARIOS: readonly Scenario[] =
.filter(scenario => scenario.rules === 'advanced' && .filter(scenario => scenario.rules === 'advanced' &&
(!profile.endsWith('tech') || scenario.roster.length >= 3)) (!profile.endsWith('tech') || scenario.roster.length >= 3))
.map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 4000 + band * 100 + index }))); .map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 4000 + band * 100 + index })));
const NATIVE_DISCOVERY_SKILLS_SCENARIOS: readonly Scenario[] =
(['native-discovery-skills', 'native-discovery-skills-tech', 'native-discovery-skills-stronghold', 'native-discovery-skills-stronghold-tech'] as const)
.flatMap((profile, band) => [...IX_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS,
...CHOAM_SKILLS_SCENARIOS, ...RICHESE_SKILLS_SCENARIOS,
...ECAZ_SKILLS_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS]
.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 STRONGHOLD_FACTIONS_ROSTER: readonly FactionId[] = [ const STRONGHOLD_FACTIONS_ROSTER: readonly FactionId[] = [
'ixians', 'choam', 'emperor', 'fremen', 'harkonnen', 'beneGesserit', 'ixians', 'choam', 'emperor', 'fremen', 'harkonnen', 'beneGesserit',
]; ];
@@ -500,7 +514,7 @@ const SKILLS_STRONGHOLD_SCENARIOS: readonly Scenario[] =
const strongholdSkillProfile = (profile: string) => profile === 'skills-stronghold' || profile === 'skills-stronghold-tech'; const strongholdSkillProfile = (profile: string) => profile === 'skills-stronghold' || profile === 'skills-stronghold-tech';
const discoverySkillsProfile = (profile: string) => profile === 'discovery-skills' || profile === 'discovery-skills-tech' || const discoverySkillsProfile = (profile: string) => profile === 'discovery-skills' || profile === 'discovery-skills-tech' ||
profile === 'discovery-skills-stronghold' || profile === 'discovery-skills-stronghold-tech'; profile === 'discovery-skills-stronghold' || profile === 'discovery-skills-stronghold-tech';
const skillProfile = (profile: string) => 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) => 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' || const discoveryProfile = (profile: string) => profile === 'discovery' || profile === 'discovery-tech' ||
profile === 'discovery-stronghold' || profile === 'discovery-stronghold-tech'; profile === 'discovery-stronghold' || profile === 'discovery-stronghold-tech';
const assassinationScenario = (scenario: Scenario) => scenario.rules === 'advanced' && const assassinationScenario = (scenario: Scenario) => scenario.rules === 'advanced' &&
@@ -546,6 +560,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] ' + '[--seed UINT32] [--profile all|base|discovery|discovery-tech|discovery-skills|discovery-skills-tech|discovery-nexus|discovery-nexus-tech|discovery-nexus-stronghold|discovery-nexus-stronghold-tech|choam|ecaz|ecaz-treachery|ecaz-occupy|moritani-assassinate|stronghold-factions|factions-tech|stronghold-factions-tech|combined|combined-nexus|combined-homeworld-nexus|homeworld|homeworld-occupation|nexus|nexus-skills|paired-nexus-skills|nexus-skills-tech|nexus-skills-stronghold|nexus-skills-stronghold-tech|paired-nexus-skills-tech|paired-nexus-skills-stronghold|paired-nexus-skills-stronghold-tech|e3-nexus-skills|e3-nexus-skills-tech|e3-nexus-skills-stronghold|e3-nexus-skills-stronghold-tech|nexus-tech|nexus-stronghold|nexus-stronghold-tech|paired-nexus-tech|paired-nexus-stronghold|paired-nexus-stronghold-tech|homeworld-nexus|choam-roster|ecaz-roster|ix-roster|choam-nexus|ecaz-nexus|ix-nexus|moritani-skills|tleilaxu-skills|ix-skills|choam-skills|richese-skills|ecaz-skills|skills-tech|skills-stronghold|skills-stronghold-tech] ' +
'[--rules both|basic|advanced] [--players all|2|3|4|5|6] [--max-actions POSITIVE] ' + '[--rules both|basic|advanced] [--players all|2|3|4|5|6] [--max-actions POSITIVE] ' +
'[--resume FAILED_GAME.json]\n' + '[--resume FAILED_GAME.json]\n' +
'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' + '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.' + '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.' +
'\nEcaz-occupy supports Basic even-force and Advanced: native Ecaz plus classic/Ixian/Tleilaxu/CHOAM/Richese paired or mixed rosters, two through six seats; selected E3/E1/E2 sources determine ordinary33/47/35 cards. Basic uses Ecaz/classic/optional Moritani and the exact ecaz deck, with E/2 mandatory contribution and losses. An uncanceled odd Basic count remains blocked by the preserved publisher casualty-rounding conflict. Explicit independent three-card samples additionally use36/50/38. Richese marker-only or ordinary pools retain original reveal timing; own mixed No-Field dialing, whole-plan inspection and optional overlays remain guarded.' + '\nEcaz-occupy supports Basic even-force and Advanced: native Ecaz plus classic/Ixian/Tleilaxu/CHOAM/Richese paired or mixed rosters, two through six seats; selected E3/E1/E2 sources determine ordinary33/47/35 cards. Basic uses Ecaz/classic/optional Moritani and the exact ecaz deck, with E/2 mandatory contribution and losses. An uncanceled odd Basic count remains blocked by the preserved publisher casualty-rounding conflict. Explicit independent three-card samples additionally use36/50/38. Richese marker-only or ordinary pools retain original reveal timing; own mixed No-Field dialing, whole-plan inspection and optional overlays remain guarded.' +
@@ -597,6 +612,8 @@ function positive(value: string | undefined) {
} }
function scenarioName(scenario: Scenario) { function scenarioName(scenario: Scenario) {
if (nativeDiscoverySkillsProfile(scenario.profile))
return `${scenario.profile}-${scenario.expansions.join('-')}-${scenario.roster.join('-')}-${scenario.roster.length}-${scenario.rules}`;
if (discoveryProfile(scenario.profile)) if (discoveryProfile(scenario.profile))
return `${scenario.profile}-${scenario.expansions[0] ?? 'classic'}-${scenario.roster[0]}-${scenario.roster.length}-${scenario.rules}`; return `${scenario.profile}-${scenario.expansions[0] ?? 'classic'}-${scenario.roster[0]}-${scenario.roster.length}-${scenario.rules}`;
if (scenario.profile === 'homeworld-occupation') if (scenario.profile === 'homeworld-occupation')
@@ -692,7 +709,7 @@ function freshGame(scenario: Scenario) {
game.homeworlds = { custody: null }; game.homeworlds = { custody: null };
if (scenario.profile === 'paired-nexus-skills' || scenario.profile === 'nexus-skills' || scenario.profile === 'nexus' || nexusModuleProfile(scenario.profile) || scenario.profile === 'homeworld-nexus' || scenario.profile === 'choam-nexus' || scenario.profile === 'ecaz-nexus' || scenario.profile === 'ix-nexus' || scenario.profile === 'combined-nexus' || scenario.profile === 'combined-homeworld-nexus') if (scenario.profile === 'paired-nexus-skills' || scenario.profile === 'nexus-skills' || scenario.profile === 'nexus' || nexusModuleProfile(scenario.profile) || scenario.profile === 'homeworld-nexus' || scenario.profile === 'choam-nexus' || scenario.profile === 'ecaz-nexus' || scenario.profile === 'ix-nexus' || scenario.profile === 'combined-nexus' || scenario.profile === 'combined-homeworld-nexus')
game.nexusCards = { cards: null, phase: null }; game.nexusCards = { cards: null, phase: null };
if (discoverySkillsProfile(scenario.profile) || discoveryNexusProfile(scenario.profile)) game.discoveryEnabled = true; if (nativeDiscoverySkillsProfile(scenario.profile) || discoverySkillsProfile(scenario.profile) || discoveryNexusProfile(scenario.profile)) game.discoveryEnabled = true;
if (discoveryProfile(scenario.profile)) { if (discoveryProfile(scenario.profile)) {
game.discoveryEnabled = true; game.discoveryEnabled = true;
return initializeDiscoveryGameForAudit(game); return initializeDiscoveryGameForAudit(game);
@@ -762,9 +779,9 @@ function resumedGame(path: string) {
game.mentatQuestionPreview game.mentatQuestionPreview
) )
throw new Error('--resume sample scenarios exclude unsupported optional modules.'); throw new Error('--resume sample scenarios exclude unsupported optional modules.');
const scenario = [...SCENARIOS, ...COMBINED_NEXUS_SCENARIOS, ...COMBINED_HOMEWORLD_NEXUS_SCENARIOS, ...BASE_SCENARIOS, ...MODULE_SCENARIOS, ...EXPANSION_ROSTER_SCENARIOS, ...ECAZ_TREACHERY_SCENARIOS, ...ECAZ_OCCUPY_SCENARIOS, ...PAIRED_NEXUS_SCENARIOS, ...MORITANI_ASSASSINATE_SCENARIOS, ...STRONGHOLD_FACTIONS_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...IX_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS, ...RICHESE_SKILLS_SCENARIOS, ...ECAZ_SKILLS_SCENARIOS, ...SKILLS_TECH_SCENARIOS, ...SKILLS_STRONGHOLD_SCENARIOS, ...NATIVE_TECH_SCENARIOS, ...CLASSIC_NEXUS_MODULE_SCENARIOS, ...PAIRED_NEXUS_MODULE_SCENARIOS, ...NEXUS_SKILLS_SCENARIOS, ...PAIRED_NEXUS_SKILLS_SCENARIOS, ...NEXUS_SKILLS_MODULE_SCENARIOS, ...PAIRED_NEXUS_SKILLS_MODULE_SCENARIOS, ...E3_NEXUS_SKILLS_SCENARIOS, ...HOMEWORLD_OCCUPATION_SCENARIOS, ...DISCOVERY_SCENARIOS, ...DISCOVERY_SKILLS_SCENARIOS, ...DISCOVERY_NEXUS_SCENARIOS, ...DISCOVERY_STRONGHOLD_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].find(
(candidate) => (candidate) =>
(discoveryProfile(candidate.profile) || discoverySkillsProfile(candidate.profile) || discoveryNexusProfile(candidate.profile)) === discovery && (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 && (candidate.profile === 'homeworld' || candidate.profile === 'homeworld-occupation' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.homeworlds &&
(candidate.profile === 'paired-nexus-skills' || candidate.profile === 'nexus-skills' || candidate.profile === 'nexus' || nexusModuleProfile(candidate.profile) || candidate.profile === 'homeworld-nexus' || candidate.profile === 'choam-nexus' || candidate.profile === 'ecaz-nexus' || candidate.profile === 'ix-nexus' || candidate.profile === 'combined-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.nexusCards && (candidate.profile === 'paired-nexus-skills' || candidate.profile === 'nexus-skills' || candidate.profile === 'nexus' || nexusModuleProfile(candidate.profile) || candidate.profile === 'homeworld-nexus' || candidate.profile === 'choam-nexus' || candidate.profile === 'ecaz-nexus' || candidate.profile === 'ix-nexus' || candidate.profile === 'combined-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.nexusCards &&
(candidate.profile === 'homeworld-occupation') === !!game.homeworldOccupationPreview && (candidate.profile === 'homeworld-occupation') === !!game.homeworldOccupationPreview &&
@@ -773,7 +790,7 @@ function resumedGame(path: string) {
(discoveryStrongholdProfile(candidate.profile) || nativeStrongholdProfile(candidate.profile) || strongholdSkillProfile(candidate.profile) || nexusStrongholdProfile(candidate.profile)) === !!game.strongholdCards && (discoveryStrongholdProfile(candidate.profile) || nativeStrongholdProfile(candidate.profile) || strongholdSkillProfile(candidate.profile) || nexusStrongholdProfile(candidate.profile)) === !!game.strongholdCards &&
(candidate.profile === 'ecaz-treachery' || candidate.ecazTreachery === true) === !!game.ecazTreachery && (candidate.profile === 'ecaz-treachery' || candidate.ecazTreachery === true) === !!game.ecazTreachery &&
assassinationScenario(candidate) === !!game.moritaniAssassinatePreview && assassinationScenario(candidate) === !!game.moritaniAssassinatePreview &&
(candidate.profile === 'ecaz-occupy' || ((nativeTechProfile(candidate.profile) || discoveryProfile(candidate.profile)) && candidate.roster.includes('ecaz'))) === !!game.ecazOccupyPreview && (candidate.profile === 'ecaz-occupy' || ((nativeDiscoverySkillsProfile(candidate.profile) || nativeTechProfile(candidate.profile) || discoveryProfile(candidate.profile)) && candidate.roster.includes('ecaz'))) === !!game.ecazOccupyPreview &&
assassinationScenario(candidate) === !!game.moritaniAssassinate && assassinationScenario(candidate) === !!game.moritaniAssassinate &&
candidate.rules === (game.advanced ? 'advanced' : 'basic') && candidate.rules === (game.advanced ? 'advanced' : 'basic') &&
JSON.stringify(candidate.expansions) === JSON.stringify(candidate.expansions) ===
@@ -993,7 +1010,8 @@ async function main() {
if ((discoveryTechProfile(profile) || profile === 'discovery-nexus-tech' || profile === 'discovery-nexus-stronghold-tech') && players === 2) if ((discoveryTechProfile(profile) || profile === 'discovery-nexus-tech' || profile === 'discovery-nexus-stronghold-tech') && players === 2)
throw new Error('Discovery with Tech Tokens requires three through six players.'); throw new Error('Discovery with Tech Tokens requires three through six players.');
const resume = values.resume ? resumedGame(values.resume) : null; const resume = values.resume ? resumedGame(values.resume) : null;
const requestedSamples = discoveryStrongholdProfile(profile) ? DISCOVERY_STRONGHOLD_SCENARIOS const requestedSamples = nativeDiscoverySkillsProfile(profile) ? NATIVE_DISCOVERY_SKILLS_SCENARIOS
: discoveryStrongholdProfile(profile) ? DISCOVERY_STRONGHOLD_SCENARIOS
: discoveryNexusProfile(profile) ? DISCOVERY_NEXUS_SCENARIOS : discoveryNexusProfile(profile) ? DISCOVERY_NEXUS_SCENARIOS
: discoverySkillsProfile(profile) ? DISCOVERY_SKILLS_SCENARIOS : discoverySkillsProfile(profile) ? DISCOVERY_SKILLS_SCENARIOS
: discoveryProfile(profile) ? DISCOVERY_SCENARIOS : discoveryProfile(profile) ? DISCOVERY_SCENARIOS
+1 -1
View File
@@ -42,7 +42,7 @@ 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 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.', '\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.', '\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 classic/base33/all14 Skills with original Discovery7/8 at2–6 seats Basic/Advanced. Unused original Tech requires3–6; original Strongholds requires Advanced2–6. Original setup, end-Mentat held claims, typed free entry, paid nested routes, carried fixed-three movement, stash limits, trained battles and Bureaucrat invoices use existing controls. Native families, Nexus/HW, Banker/Mentat previews, pending rulings, public starts and save conversion are not opened.', '\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.', '\nNexus --discoveries explicitly composes fresh classic/base33/all12 Nexus with original Discovery7/8 at2–6 seats Basic/Advanced. Unused Tech requires3+; optional Strongholds require Advanced; both3+. Actual Great Maker/worm/vote/reserve ride, original settled-alliance end-Spice closing card deal and end-Mentat card ownership/next entry remain. Source-clear revival/tariff/battle effects reuse original handlers. Skills/HW/native families, mixed-module Fremen Betrayal, pending rulings, public activation and save conversion stay guarded.',
); );
return; return;