diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index ec5aaccf..b41b3257 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -1,6 +1,6 @@ # 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. Expansion starts and full rules/publication acceptance remain gated.** @@ -17,6 +17,37 @@ open mode gates. ## 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:** fresh original Discovery and separate classic Skills/Discovery preserve Advanced Strongholds2–6, optional Tech3–6, original setup/held claims, diff --git a/docs/DISCOVERY_PROTOTYPE.md b/docs/DISCOVERY_PROTOTYPE.md index 500c9c43..ed6869de 100644 --- a/docs/DISCOVERY_PROTOTYPE.md +++ b/docs/DISCOVERY_PROTOTYPE.md @@ -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 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 Fresh local `nexus --discoveries` composes classic/base33/all12 Nexus with the diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index ce5bb996..83a1b1f8 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,42 @@ # 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 Fresh original Discovery classic/E1/E2/standalone E3 and separate classic diff --git a/docs/LEADER_SKILLS_RUNTIME.md b/docs/LEADER_SKILLS_RUNTIME.md index 45ac27b0..d9c0df50 100644 --- a/docs/LEADER_SKILLS_RUNTIME.md +++ b/docs/LEADER_SKILLS_RUNTIME.md @@ -5,6 +5,27 @@ Normal mode starts and publication remain gated. The [source contract](LEADER_SK contains all fourteen physical cards; the [capture interpretation](LEADER_SKILLS_CAPTURE.md) records the fresh publisher/designer search requested by the user. +## 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` profile connects actual final-bank payment legs, one physical Banker phase grant and automatic current Mentat collection. The full deck14 diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index aa960583..55300400 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -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) | | 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) | +| 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) | | 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) | diff --git a/docs/SMUGGLER_BATTLE.md b/docs/SMUGGLER_BATTLE.md index 4c9323ba..4b661d98 100644 --- a/docs/SMUGGLER_BATTLE.md +++ b/docs/SMUGGLER_BATTLE.md @@ -4,6 +4,19 @@ the base-faction Leader Skills preview connect the lower Smuggler effect in Basic 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 The [source follow-up](LEADER_SKILLS_RULES.md#20-september-smuggler-follow-up) diff --git a/game/bots.ts b/game/bots.ts index 9f35bfbd..a35dc959 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -4,7 +4,7 @@ import { quoteEcazOccupyDial } from './ecaz-occupy-battle'; import { recruitsPlayAction } from './recruits'; import type { Card } from './cards'; 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 { mirrorWeaponModeBlock } from './mirror-weapon-mode'; 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 d = me.hand?.find((c) => c.id === defense); 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; if (!battleCardSlotEligible('weapon', w, {planetologistWeapon: specialForLeader(w, leader)})) continue; const stoneBattle = diff --git a/game/bureaucrat-payment.ts b/game/bureaucrat-payment.ts index f80c7f9b..f591280b 100644 --- a/game/bureaucrat-payment.ts +++ b/game/bureaucrat-payment.ts @@ -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'; export type BureaucratPaymentKind = 'auction' | 'shipment' | 'bribe' | 'revival'; export type BureaucratPaymentSource = { @@ -39,7 +39,7 @@ export function bureaucratPaymentModeSupported(game: LeaderSkillProfile): boolea return (noOtherLeaderSkillModules(game) && (!game.expansions.length || (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( used: readonly BureaucratPaymentUse[], diff --git a/game/engine.ts b/game/engine.ts index 016cd1f6..2b4c5986 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1,7 +1,7 @@ import { classicNexusModulesProfile, pairedNexusModulesProfile } from './nexus-module-profile'; import { nativeFactionTechProfile } from './faction-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 { quoteSpicePlacement, stormExposesTerritory, stormSectorAfter, wormConsumesForces } from './disaster-rules'; import { isStormCardDistance, type StormCardComponent } from './storm-cards'; @@ -7613,7 +7613,8 @@ function e3StrongholdFactionProfile(g: Game): boolean { } function moritaniAssassinateModeSupported(g: Game) { 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'); return g.advanced && g.expansions.length === 1 && g.expansions[0] === 'ecaz' && 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; if (g.ecazOccupyPreview === true && g.discoveries && discoveryModeSupported(g) && 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) && g.expansions.includes('ecaz') && 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 { requireRule(!state.leaderSkills, 'Leader Skills cannot redeal existing skill cards.'); 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); requireFreshFactionInventory(state); } const g = structuredClone(state); 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)); if (advancedMoritaniLeaderSkillsProfile(initialized)) initializeMoritaniAssassinateState(initialized); + if (nativeDiscoveryLeaderSkillsProfile(initialized) && byFaction(initialized, 'ecaz')) initialized.ecazOccupyPreview = true; return initialized; } /** 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 nativeTech = factions && !leaderSkills && nativeFactionTechProfile(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) || (leaderSkills && (classicNexusLeaderSkillsProfile(g) || pairedNexusLeaderSkillsProfile(g) || standaloneE3NexusLeaderSkillsProfile(g)))); requireRule(!g.techTokens || ((skillTech || nativeTech || nexusModules || discoveryComposition) && @@ -32564,6 +32568,7 @@ export function viewGame(state: Game, id: string) { : null, lateDefense: b.revealed ? (b.lateDefense ?? {}) : {}, smugglerCollectionEnabled: b.smugglerCollectionVersion === 1, + smugglerCollectionSupported: smugglerBattleModeSupported(g), smugglerCollection: b.revealed && b.smugglerCollection ? { player: b.smugglerCollection.player, leader: b.smugglerCollection.leader, amount: b.smugglerCollection.amount, before: b.smugglerCollection.before } diff --git a/game/leader-skill-profile.ts b/game/leader-skill-profile.ts index ab72b746..0b1dfa64 100644 --- a/game/leader-skill-profile.ts +++ b/game/leader-skill-profile.ts @@ -109,9 +109,27 @@ export function standaloneE3NexusLeaderSkillsProfile(game: LeaderSkillProfile): (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. */ function nativeLeaderSkillModulesSupported(game: LeaderSkillProfile): boolean { if (noOtherLeaderSkillModules(game) || pairedNexusLeaderSkillsProfile(game)) return true; + if (game.discoveryEnabled || game.discoveries) return discoveryLeaderSkillModulesSupported(game); const players = game.players; if ((!game.techTokens && !game.strongholdCards) || !players || 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. */ function e3LeaderSkillModulesSupported(game: LeaderSkillProfile): boolean { if (noOtherLeaderSkillModules(game) || standaloneE3NexusLeaderSkillsProfile(game)) return true; + if (game.discoveryEnabled || game.discoveries) return discoveryLeaderSkillModulesSupported(game); const players = game.players; if ((!game.techTokens && !game.strongholdCards) || !players || players.length < (game.techTokens ? 3 : 2) || players.length > 6 || @@ -346,6 +365,11 @@ export function nativeExpansionLeaderSkillsProfile(game: LeaderSkillProfile): bo 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. */ export function nativeTechLeaderSkillsProfile(game: LeaderSkillProfile): boolean { return !!game.techTokens && nativeExpansionLeaderSkillsProfile(game); @@ -358,5 +382,5 @@ export function ordinaryLeaderSkillModeSupported(game: LeaderSkillProfile): bool classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game) || strongholdLeaderSkillsProfile(game) || classicNexusLeaderSkillsProfile(game) || pairedNexusLeaderSkillsProfile(game) || standaloneE3NexusLeaderSkillsProfile(game) || - classicDiscoveryLeaderSkillsProfile(game); + classicDiscoveryLeaderSkillsProfile(game) || nativeDiscoveryLeaderSkillsProfile(game); } diff --git a/game/reference.ts b/game/reference.ts index 414f9c70..865ac7d4 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -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', 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.', - '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.', '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.', @@ -4101,7 +4101,7 @@ export const RULE_TOPICS: RuleTopic[] = [ checklist: [ { 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'], }, { @@ -4121,8 +4121,8 @@ export const RULE_TOPICS: RuleTopic[] = [ }, { 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.', - 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'], + 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-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'], }, ], }, diff --git a/tests/discovery-module-profile.test.ts b/tests/discovery-module-profile.test.ts index 36a54a77..36de9554 100644 --- a/tests/discovery-module-profile.test.ts +++ b/tests/discovery-module-profile.test.ts @@ -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); }); -void test('classic Discovery Skills does not silently drop native families or unrelated modules and previews', () => { - const native = lobby(['ixians', 'guild', 'fremen'], ['ix']); - assert.throws(() => initializeLeaderSkillsGameForAudit(native), RuleError); +void test('Discovery Skills cannot silently drop unsupported native families or unrelated modules and previews', () => { + const unsupported = lobby(['ixians', 'ecaz', 'guild'], ['ix', 'ecaz']); + assert.throws(() => initializeLeaderSkillsGameForAudit(unsupported), RuleError); const overlays = Array.from({ length: 5 }, () => lobby(['guild', 'emperor', 'harkonnen'], [])); overlays[0].homeworlds = { custody: null }; overlays[1].nexusCards = { cards: null, phase: null }; diff --git a/tests/discovery-native-e1-skills-runtime.test.ts b/tests/discovery-native-e1-skills-runtime.test.ts new file mode 100644 index 00000000..01d03af2 --- /dev/null +++ b/tests/discovery-native-e1-skills-runtime.test.ts @@ -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.'); + } +}); diff --git a/tests/discovery-native-e2-skills-runtime.test.ts b/tests/discovery-native-e2-skills-runtime.test.ts new file mode 100644 index 00000000..01a278de --- /dev/null +++ b/tests/discovery-native-e2-skills-runtime.test.ts @@ -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); + } +}); diff --git a/tests/discovery-native-e3-skills-runtime.test.ts b/tests/discovery-native-e3-skills-runtime.test.ts new file mode 100644 index 00000000..daeaeb34 --- /dev/null +++ b/tests/discovery-native-e3-skills-runtime.test.ts @@ -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); +}); diff --git a/tests/fixture-discovery-native-e1-skills.ts b/tests/fixture-discovery-native-e1-skills.ts new file mode 100644 index 00000000..f552668e --- /dev/null +++ b/tests/fixture-discovery-native-e1-skills.ts @@ -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 }; +} diff --git a/tests/fixture-discovery-native-e2-skills.ts b/tests/fixture-discovery-native-e2-skills.ts new file mode 100644 index 00000000..b495c025 --- /dev/null +++ b/tests/fixture-discovery-native-e2-skills.ts @@ -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(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 = (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(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 = (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 = {}): 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); +} diff --git a/tests/fixture-discovery-native-e3-skills.ts b/tests/fixture-discovery-native-e3-skills.ts new file mode 100644 index 00000000..e911406f --- /dev/null +++ b/tests/fixture-discovery-native-e3-skills.ts @@ -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(operation: () => T, choose: (index: number) => number): T { + const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); + const original = crypto.getRandomValues.bind(crypto); let index = 0; + crypto.getRandomValues = (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(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 }; +} diff --git a/tools/faction-games.ts b/tools/faction-games.ts index ac53199e..7dec7f7d 100644 --- a/tools/faction-games.ts +++ b/tools/faction-games.ts @@ -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' | 'discovery' | 'discovery-tech' | 'discovery-skills' | 'discovery-skills-tech' | 'discovery-stronghold' | 'discovery-stronghold-tech' | 'discovery-skills-stronghold' | 'discovery-skills-stronghold-tech' + | 'native-discovery-skills' | 'native-discovery-skills-tech' | 'native-discovery-skills-stronghold' | 'native-discovery-skills-stronghold-tech' | 'discovery-nexus' | 'discovery-nexus-tech' | 'discovery-nexus-stronghold' | 'discovery-nexus-stronghold-tech'; const discoveryStrongholdProfile = (profile: string) => profile === 'discovery-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' || 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 Scenario = { @@ -422,6 +428,14 @@ const DISCOVERY_STRONGHOLD_SCENARIOS: readonly Scenario[] = .filter(scenario => scenario.rules === 'advanced' && (!profile.endsWith('tech') || scenario.roster.length >= 3)) .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[] = [ '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 discoverySkillsProfile = (profile: string) => profile === 'discovery-skills' || profile === 'discovery-skills-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' || profile === 'discovery-stronghold' || profile === 'discovery-stronghold-tech'; 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] ' + '[--rules both|basic|advanced] [--players all|2|3|4|5|6] [--max-actions POSITIVE] ' + '[--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' + '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.' + @@ -597,6 +612,8 @@ function positive(value: string | undefined) { } 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)) return `${scenario.profile}-${scenario.expansions[0] ?? 'classic'}-${scenario.roster[0]}-${scenario.roster.length}-${scenario.rules}`; if (scenario.profile === 'homeworld-occupation') @@ -692,7 +709,7 @@ function freshGame(scenario: Scenario) { 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') 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)) { game.discoveryEnabled = true; return initializeDiscoveryGameForAudit(game); @@ -762,9 +779,9 @@ function resumedGame(path: string) { game.mentatQuestionPreview ) throw new Error('--resume sample scenarios exclude unsupported optional modules.'); - const scenario = [...SCENARIOS, ...COMBINED_NEXUS_SCENARIOS, ...COMBINED_HOMEWORLD_NEXUS_SCENARIOS, ...BASE_SCENARIOS, ...MODULE_SCENARIOS, ...EXPANSION_ROSTER_SCENARIOS, ...ECAZ_TREACHERY_SCENARIOS, ...ECAZ_OCCUPY_SCENARIOS, ...PAIRED_NEXUS_SCENARIOS, ...MORITANI_ASSASSINATE_SCENARIOS, ...STRONGHOLD_FACTIONS_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...IX_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS, ...RICHESE_SKILLS_SCENARIOS, ...ECAZ_SKILLS_SCENARIOS, ...SKILLS_TECH_SCENARIOS, ...SKILLS_STRONGHOLD_SCENARIOS, ...NATIVE_TECH_SCENARIOS, ...CLASSIC_NEXUS_MODULE_SCENARIOS, ...PAIRED_NEXUS_MODULE_SCENARIOS, ...NEXUS_SKILLS_SCENARIOS, ...PAIRED_NEXUS_SKILLS_SCENARIOS, ...NEXUS_SKILLS_MODULE_SCENARIOS, ...PAIRED_NEXUS_SKILLS_MODULE_SCENARIOS, ...E3_NEXUS_SKILLS_SCENARIOS, ...HOMEWORLD_OCCUPATION_SCENARIOS, ...DISCOVERY_SCENARIOS, ...DISCOVERY_SKILLS_SCENARIOS, ...DISCOVERY_NEXUS_SCENARIOS, ...DISCOVERY_STRONGHOLD_SCENARIOS].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) => - (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 === '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 && @@ -773,7 +790,7 @@ function resumedGame(path: string) { (discoveryStrongholdProfile(candidate.profile) || nativeStrongholdProfile(candidate.profile) || strongholdSkillProfile(candidate.profile) || nexusStrongholdProfile(candidate.profile)) === !!game.strongholdCards && (candidate.profile === 'ecaz-treachery' || candidate.ecazTreachery === true) === !!game.ecazTreachery && assassinationScenario(candidate) === !!game.moritaniAssassinatePreview && - (candidate.profile === 'ecaz-occupy' || ((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 && candidate.rules === (game.advanced ? 'advanced' : 'basic') && 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) throw new Error('Discovery with Tech Tokens requires three through six players.'); 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 : discoverySkillsProfile(profile) ? DISCOVERY_SKILLS_SCENARIOS : discoveryProfile(profile) ? DISCOVERY_SCENARIOS diff --git a/tools/start-prototype.ts b/tools/start-prototype.ts index 717c7303..063bec50 100644 --- a/tools/start-prototype.ts +++ b/tools/start-prototype.ts @@ -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 Ecaz is seated, this original fresh entry separately marks its printed high Homeworld alliance victory: current native seven-plus, one actual joint stronghold, two distinct other native factions with proved current occupied holdings. It adds no ordinary stronghold/Tech points, pays no reward and changes no Duke authority. Public progress remains prospective until original Mentat; qualifying members join original winners before BG prediction/final fallback. Unmarked played/public profiles remain unchanged.', '\nDiscovery opts fresh classic, selected E1/E2 native families or standalone Ecaz OR Moritani with classics into original Discovery setup in Basic/Advanced2–6, optionally retaining unused original Tech3–6 and Advanced Strongholds2–6. Original decks/cache, HMS/Face Dancers, typed entry, revealed-only nested No-Fields, stash limits, end-Mentat held claims and native outcomes remain. Advanced Moritani excludes Harkonnen. Skills/HW/Nexus, paired/mixed E3, contested/shared-payer rulings, public starts and played-game conversion stay separate.', - '\nLeader-skills --discoveries explicitly composes fresh 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.', ); return;