diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index f3419840..0169eeae 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,17 +16,28 @@ open mode gates. ## Current checkpoint and work -**In progress — Advanced Skills / Stronghold Cards, with optional Tech:** -parent owns shared eligibility, original initializer/opt-ins, sample CLI and -guide. Two bounded case owners own classic support/defense/income/rescue and -native HMS/typed losses/FaceDance/marker outcomes in separate new -`fixture-*-skills-stronghold.ts` / `*-skills-stronghold-runtime.test.ts`. -Contract: original classic or supported E1/E2 native2..6 Advanced rosters/decks, -all14 skills/six cards; optional Tech requires3..6. Canonical unused lobby -components, original setup, real end-Mentat Stronghold ownership and printed/ -first-Storm Tech owners only. Basic Strongholds, E3/other overlays, public -starts, played-game conversion and unrelated rulings stay excluded. -Current programs are not yet exercised; full goal remains unfinished. +**Advanced Skills / Stronghold Cards — bounded Development Verified:** +fresh classic or supported E1/E2 native2..6 Advanced preserves original +all14 skills/six unused cards; optional Tech requires3..6. Original setup, +decks/cache/wallets, real end-Mentat card control and printed/first-Storm Tech +remain. No Basic Strongholds, E3/other overlays, public starts, save conversion +or unrelated rulings. New21/affected277/final fixture-consumer72 cases, +types/lint/build and twelve actual rule programs pass. Twenty-two genuine +paired/triple-module3-seat games complete6,672 actions/no rejection and171 +JSON continuations on captured unchanged source. Original CLI **J5AAWGEQ v8** +and **NY6Y4J4K v10** retain real setup and unused Strongholds/native Tech. +Human390px **HERCFX3B v20/Mentat** pays4 after Arrakeen bank2, rescues3, +retains Shield and original winner Tech, then collects separate Banker12→13. +**PG2GR3RC v26/Collection** declares real HMS copy and uses bank1/own0, +typed4 Suboid+2 cyborg losses and real rescue before original Axlotl reward. +Market/phase openings remain native; refresh retains both versions/outcomes. +Controlled positions are labeled, not claimed natural histories. Original +copied-Diplomat/skill-Stone and other guards remain. See +[combined scope and source evidence](LEADER_SKILLS_RUNTIME.md#advanced-stronghold-composition--4-october-2026). +Full combinations, assurance, AI, public and deployed acceptance remain open. +Final guide10/10, types/lint/build and rebuilt390px reference pass (351px topic, +no overflow); original human v20/Mentat andv26/Collection remain after the +final isolated QA reload. Owned drivers/tabs are removed; rooms/store remain. **Native E1/E2 Leader Skills / Tech Tokens — bounded Development Verified:** fresh supported Ixian/Tleilaxu/CHOAM/Richese Basic/Advanced3..6-seat entries diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 66cc3b2c..80be95fc 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,37 @@ # Dune implementation status +## 4 October 2026 — Advanced Skills and Strongholds, with optional Tech + +Two bounded owners supplied classic subsidy/defense/income/rescue and native +HMS/typed losses/FaceDance/marker programs; parent integrated public-config +eligibility, canonical unused-component admission, original Banker/Mentat +opt-ins and sample CLI. Advanced classic or supported E1/E2 native2..6 retains +all14 skills/six cards; Tech requires3..6. Deck/cache, wallets and actual setup, +end-Mentat claims and printed/first-Storm Tech remain. E3/other overlays, +Basic Strongholds, public starts, save conversion and unrelated guards remain. + +New21/affected277 and final fixture-consumer72 cases, types/lint/build pass. +Original setup probes cover26 valid entries/two below-three Tech rejections. +Twelve actual programs and twenty-two genuine paired/triple3-seat games pass: +6,672 accepted actions/no rejection,171 JSON continuations, captured unchanged +tree `171ecf79ccdef187ca662ddd00c0e5d3c5bce5231d386ee16566ac062ba5dc21`. +Fixtures use real nonmandatory winner cards before Collection, a funded bribe +before cash-boundary rejection, explicit typed casualty choice and original +mutual-traitor support payment. The no-spend fixture follows the original +response pass queue rather than treating bot recommendations as a legal menu. +Two explicit type annotations and exact per-token owner comparison fix fixture +inference/lint findings without weakening the custody comparison. + +Original CLI J5AAWGEQv8/NY6Y4J4Kv10 and human390px HERCFX3Bv20/Mentat, +PG2GR3RCv26/Collection retain original IDs, legal payments, cards/forces and +Tech. Actual classic own4 after bank2 qualifies one unavailable Banker grant; +trained rescue retains support, and native readiness collects12→13 once. +Native copy bank1/own0 preserves wallet10 before original Collection2; +typed rescue keeps original subtype custody before winner cleanup/Axlotl. +Detailed current scope, source pages and human outcomes remain in +[the existing skill runtime](LEADER_SKILLS_RUNTIME.md#advanced-stronghold-composition--4-october-2026). +No full-module, assurance, strategic-AI, public or deployed acceptance claim. + ## 4 October 2026 — native E1/E2 Leader Skills with original Tech Tokens Three bounded owners supplied independent Ixian/CHOAM, Tleilaxu and Richese diff --git a/docs/LEADER_SKILLS_RUNTIME.md b/docs/LEADER_SKILLS_RUNTIME.md index e6ec5f89..1ad19a80 100644 --- a/docs/LEADER_SKILLS_RUNTIME.md +++ b/docs/LEADER_SKILLS_RUNTIME.md @@ -29,6 +29,75 @@ Advanced native/combined-module skills remain guarded. See [native preview scope](MENTAT_QUESTION.md#native-skill-integration--2-october-2026). +## Advanced Stronghold composition — 4 October 2026 + +Fresh Advanced `leader-skills` and separate `banker-income` entries preserve +canonical unused Stronghold Cards with all fourteen skills in classic or +already-supported E1/E2 native two-through-six-seat lobbies. Optional original +Tech Tokens requires three-through-six seats. Original deck/roster restrictions, +Richese cache, wallets, offers and native setup remain; cards acquire owners +only at actual end Mentat. The authorized supplied source physical21 +(printed19) permits these variants together. Basic Strongholds, E3/other +overlays, public starts, save conversion and unrelated rulings stay excluded. + +Arrakeen reduces the actual force-support invoice by at most two bank spice; +only the remaining payer debit can qualify normal Banker income. Rescue never +refunds committed support. CHOAM's original income includes bank-funded support. +Physical Carthag defenses retain their roles and matching normal discipline; +modified/copied Diplomat defenses keep their existing guard. Habbanya holder +priority, Tabr opposing-dial income and Tuek played-Worthless income compose +original skill scores without replacing printed values or traitor outcomes. +Suk/native substitution, card cleanup and mandatory Tech precede Face Dance; +replacement retains the original winner's earnings and returns a killed skill +once. Hidden-skill/untrained Stone cases do not settle active skill/Stone timing. + +New21 cases and affected277/277 pass. Final shared fixture-consumer72/72, +types/lint and build pass. Actual original setup probes cover26 valid +two/three/six-seat module/Banker entries and two below-three Tech rejections. +Twelve actual rule programs cover four support thresholds, HMS typed rescue, +CHOAM support/Auditor exclusion, capped Richese marker, Tabr/Tuek Face Dance +and both cache-acquired Stone modes. Twenty-two genuine Advanced three-seat +paired/triple-module games complete6,672 accepted actions/no rejection and171 +JSON continuations on captured unchanged tree +`171ecf79ccdef187ca662ddd00c0e5d3c5bce5231d386ee16566ac062ba5dc21`. +The two sample profiles are `skills-stronghold` and `skills-stronghold-tech`. + +Fixture corrections preserve actual rules, not new interpretations: ensure a +physical nonmandatory winner card when stopping before Collection; fund a real +bribe before testing an unaffordable plan; explicitly select original cyborg +loss allocation; mutual traitors still pay original support and earn no spice. +The common no-spend fixture now follows the persisted response pass queue +directly. A bot recommendation is not a complete legal-action menu; an +uncontrolled initial card deal exposed that distinction at Atreides inspection. +No production response policy or legal rule is bypassed. + +Original CLI **J5AAWGEQ v8/setup** retains six unowned cards/three unowned Tech; +**NY6Y4J4K v10/setup** retains six unowned cards, printed Ixian Heighliners and +Fremen Production, with Axlotl awaiting original first-Storm assignment. +Human390px **HERCFX3B v20/Mentat** selects trained Staban5/dial6/support6/Shield. +Actual bank2/own4 takes native available6→2; Suk saves3, keeps1 and returns2, +leaving Arrakeen3/reserves14/Tanks3 and retained Shield. Emperor8 go to Tanks. +Only original opposing Production transfers; Guild keeps Axlotl and Atreides +keeps Heighliners. Separate Collection2 makes Guild4. Banker Atreides remains +12 with front1 through Collection; human readiness reaches Mentat13/front0. +Human390px **PG2GR3RC v26/Collection** publicly chooses Arrakeen from two real +HMS copy options, then trained C’tair5/dial5/support1/Shield. Bank covers1, +own payment0; actual4 Suboid+2 cyborg losses precede rescue2 ordinary+1 elite, +keeping ordinary and returning one of each. Original substitution is declined. +HMS3 plus Arrakeen1/Tabr1, reserves12/Tanks3; cyborg reserves6/Tanks1/board0. +Both held defenses remain; sole opposing Axlotl transfers, Fremen Production +stays. Wallet10 remains through battle; original Collection2 makes12 after the +native CHOAM market finish and original phase-opening readiness. +Ix first Mentat controls only HMS/Arrakeen; the second copy's Tabr control is +labeled later Movement staging. No later Ix Mentat victory is suppressed. +Phone cost/copy/rescue/counter controls are readable; refresh preserves both +versions and outcomes. Full combinations, assurance, strategic AI, public and +deployed acceptance remain open. +Final reference10/10, types/lint/build and rebuilt390px combined guide pass +(351px topic,390px page, no overflow). The final isolated reload retains human +v20/Mentat andv26/Collection. Owned temporary drivers and managed tabs are +removed; rooms/store, private metadata and original captures remain. + ## Native E1/E2 Tech Tokens composition — 4 October 2026 Fresh three-through-six-seat Basic/Advanced `leader-skills` and separate diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 20e0c9e5..89ec863f 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -199,6 +199,7 @@ See [the cutover and Advanced sequence](MORITANI_ASSASSINATE_LEADERS.md#authorit | Leader Skills | Fourteen physical cards, variant setup order, public/hidden assignment, death return, own-leader revival and captured lower-band effects are sourced. Five battle disciplines, Planetologist movement/Special substitution, Suk Graduate rescues and common lifecycle are prototyped; Rihani, lower Mentat/Bureaucrat/Sandmaster, normal Smuggler and normal Diplomat now have connected follow-ups. Remaining bands and combinations stay missing. Known skilled capture identifies the leader through its unique public card. | [Source contract](LEADER_SKILLS_RULES.md), [capture inference](LEADER_SKILLS_CAPTURE.md), [runtime](LEADER_SKILLS_RUNTIME.md), [Diplomat](DIPLOMAT_DEFENSE.md), [Planetologist](PLANETOLOGIST_RULES.md), [Suk Graduate](SUK_GRADUATE_RULES.md) | | Classic Leader Skills / Tech Tokens | Fresh classic3..6 Basic/Advanced, base33/all14, canonical unused three tokens and original setup/first-Storm owners. Existing leader-skills/banker-income entry preserves selected Tech; native/other overlays stay separate. Tech collects at phase end, independent Banker waits for actual Mentat; neither currency store is a recursive payment producer. Smuggler changes actual priced count; original Suk/Sandmaster precede card cleanup and mandatory token transfer. One token auto-transfers, several require choice, none skips. Mentat/Bureaucrat original bounds remain. This composes existing rules, not a new ruling or public activation. | [Scope and evidence](LEADER_SKILLS_RUNTIME.md#classic-tech-tokens-composition--4-october-2026) | | Native E1/E2 Leader Skills / Tech Tokens | Fresh supported Ixian/Tleilaxu/CHOAM/Richese3..6 Basic/Advanced keeps original native deck/roster limits, all14 skills, cache and printed Tech setup. Original typed payments/rescue/card cleanup precede mandatory winner token reward; Tleilaxu A.1–3 expressly preserves that reward before Face Dance. Actual Charity affects Production, not Axlotl; native CHOAM initial income is not poverty Charity. Separate Banker/Mentat bounds and unrelated guards remain. E3/other overlays, public starts and save conversion are excluded. This composes sourced rules, not a new interpretation. | [Scope, supplied physical21/38 and evidence](LEADER_SKILLS_RUNTIME.md#native-e1e2-tech-tokens-composition--4-october-2026) | +| Advanced Leader Skills / Stronghold Cards | Fresh classic or supported E1/E2 native2..6 Advanced preserves all14 skills, six unused cards, original decks/cache/wallets and actual end-Mentat claims; optional Tech requires3..6. Arrakeen bank subsidy is separate from actual payer debit/Banker threshold and original CHOAM income. Physical defense/normal discipline, holder tie and Tabr/Tuek income compose original skill outcomes. Suk/native cleanup and mandatory Tech precede Face Dance, retaining original rewards and one killed-skill return. Copied-Diplomat/active skill-Stone and unrelated guards remain. Basic Strongholds, E3/other overlays, public starts and save conversion are excluded. | [Authorized physical21/38, existing effects and evidence](LEADER_SKILLS_RUNTIME.md#advanced-stronghold-composition--4-october-2026) | | Native Mentat preview | Explicit fresh `leader-skills --mentat-question` reuses the existing private uniform question for already-supported Basic Ixian/Tleilaxu/CHOAM/Moritani profiles. Exact/default card roles, held fallback and skill-before-faction timing are unchanged. Ordinary/public activation, empty-hand handling, Advanced native skills and other modules stay gated; this is integration, not a new answer to the pending UX question. | [Native integration and existing authority](MENTAT_QUESTION.md#native-skill-integration--2-october-2026) | | Normal Spice Banker local preview | Fresh classic/base33 Basic/Advanced or supported Basic native skill profiles, all14 skills alone. One additional deferred bank spice per physical card/turn/phase for another payer's one actual bank payment ≥4; no payer pooling or advance spending. Tleilaxu self-revival pays Bank; other-player revival pays Tleilaxu unless canceled. The separate free-revival award never contributes to payer debit. Actual initialized Mentat collects once; original earned-faction retention and separate-payer threshold remain provisional local policies, not answers to pending custody/split questions. No public start, retrofit or other overlays. | [Normal-band contract](SPICE_BANKER_RUNTIME.md#normal-income-local-contract--2-october-2026), [native integration](SPICE_BANKER_RUNTIME.md#basic-native-banker-integration--2-october-2026), [skill authority](LEADER_SKILLS_RULES.md) | | Advanced Stronghold faction integration | Fresh `stronghold-factions` admits selected E1/E2 native families and standalone Ecaz OR Moritani with classic2..6-seat Advanced, canonical selected decks and six Stronghold Cards alone. Standalone E3 uses ecaz33; Moritani excludes Harkonnen. Ecaz co-control holds the card at Ecaz, but only the selected actual holder-plan receives its effect; fixed Ecaz strength is free, and Arrakeen funds only actual variable support. Losing Moritani retains printed Tuek income before original assassination, printed bounty and one actual Mentat replacement; winner cleanup finishes once and normal Traitor reveal forfeits assassination for the game. Existing E1/E2 No-Field/Stone/FaceDance and retained winner rewards remain. E3 pairs/mixed families, mixed No-Field/full-plan, Stone/Poison Tooth and other overlays stay guarded. This composes existing contracts, not a new ruling. Save/privacy/custody assurance follows all rules. | [E1/E2 scope](STRONGHOLD_CARDS.md#expanded-e1e2-faction-rules), [standalone E3 scope and evidence](STRONGHOLD_CARDS.md#standalone-e3-stronghold-composition--4-october-2026) | diff --git a/docs/SPICE_BANKER_RUNTIME.md b/docs/SPICE_BANKER_RUNTIME.md index d7a2d49c..05ac96bb 100644 --- a/docs/SPICE_BANKER_RUNTIME.md +++ b/docs/SPICE_BANKER_RUNTIME.md @@ -6,6 +6,15 @@ positive bank payments to automatic current Mentat collection. Bounded coverage is **Partial / Development Verified**. No public start, old-game retrofit or other module is enabled; this is not deployed acceptance. +**4 October Advanced Stronghold composition:** the same separate local entry +preserves unused Stronghold Cards in Advanced classic or supported E1/E2 +native2..6-seat skills; optional Tech still needs3..6. Arrakeen bank support +is not payer spending: declared0/2/5/6 means actual own0/0/3/4, and only4 +qualifies a grant. Human **HERCFX3B v20/Mentat** pays4 after bank2 while Suk +rescues3 without refunding support. Atreides retains wallet12/front1 until +actual Mentat, then13/front0. See the [combined source and evidence](LEADER_SKILLS_RUNTIME.md#advanced-stronghold-composition--4-october-2026). +Original earned-custody/separate-payer policies remain; no public activation. + **4 October native Tech composition:** the same separate opt-in preserves unused original Tech in fresh3..6-seat supported Ixian/Tleilaxu/CHOAM/Richese Basic/Advanced lobbies. Native deck/roster limits and payment recipients remain. diff --git a/docs/STRONGHOLD_CARDS.md b/docs/STRONGHOLD_CARDS.md index fe53dbc5..75e36859 100644 --- a/docs/STRONGHOLD_CARDS.md +++ b/docs/STRONGHOLD_CARDS.md @@ -2,6 +2,35 @@ Historical development module checkpoint, 7 September 2026: the six printed effects, public card custody, mobile declaration, support accounting, AI choices and readable faces are integrated. The later user-authorized [classic Advanced preview](ADVANCED_PREVIEW.md) already permits Stronghold Cards; preserve that access. Complete Advanced/expansion release and arbitrary combinations remain gated. +## Advanced Leader Skills composition — 4 October 2026 + +Fresh local `leader-skills` and separate `banker-income` preserve unused six +Stronghold Cards with all14 skills in Advanced classic or supported E1/E2 +native2..6-seat rosters. Optional original Tech requires3..6; native wallets, +decks/cache, setup and actual end-Mentat claims remain. The supplied authorized +Advanced source physical21/printed19 permits variants together. Basic +Strongholds, E3/other modules, public starts and save conversion stay excluded. + +Original support subsidy changes actual payer debit, not rescue losses or +the normal Banker threshold. Native CHOAM still earns on bank-funded support. +Physical Carthag/normal discipline, holder-only Habbanya tie, Tabr dial income, +Tuek played-Worthless income and HMS pre-plan copy compose source-clear skills. +Suk and original native cleanup finish before optional Tech and Face Dance; +the original winner retains earnings/cards, and killed training returns once. +Copied Diplomat/modified defense and active skill/Stone interpretations remain +guarded. No card custody is assigned to create a case. + +New21, affected277 and final shared fixture-consumer72 cases, types/lint/build, +twelve actual programs and twenty-two genuine paired/triple games pass +(6,672 accepted actions/no rejection,171 JSON continuations). +Human390px **HERCFX3B v20/Mentat** pays actual support4 after bank2, rescues3, +keeps Shield and original two Tech while separate Banker fronts1 then collects +12→13 at actual Mentat. **PG2GR3RC v26/Collection** declares real HMS copy, +bank-funded1/own0, typed4 Suboid+2 cyborg loss/rescue before sole Axlotl reward. +All original responses, market finish and phase-opening readiness remain. +Read the [combined scope and exact outcomes](LEADER_SKILLS_RUNTIME.md#advanced-stronghold-composition--4-october-2026). +This is bounded Development Verified, not complete-module/public/deployed proof. + ## Standalone E3 Stronghold composition — 4 October 2026 Fresh `stronghold-factions` additionally admits Advanced native Ecaz OR native diff --git a/game/bureaucrat-payment.ts b/game/bureaucrat-payment.ts index 07e7c3bf..6d858f18 100644 --- a/game/bureaucrat-payment.ts +++ b/game/bureaucrat-payment.ts @@ -1,4 +1,4 @@ -import { classicTechLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, noOtherLeaderSkillModules, type LeaderSkillProfile } from './leader-skill-profile'; +import { classicTechLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, noOtherLeaderSkillModules, strongholdLeaderSkillsProfile, type LeaderSkillProfile } from './leader-skill-profile'; export type BureaucratPaymentKind = 'auction' | 'shipment' | 'bribe' | 'revival'; export type BureaucratPaymentSource = { event: string; @@ -38,7 +38,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); + nativeExpansionLeaderSkillsProfile(game))) || classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game) || strongholdLeaderSkillsProfile(game); } export function bureaucratUsed( used: readonly BureaucratPaymentUse[], diff --git a/game/engine.ts b/game/engine.ts index d65b5cd9..ba1a6d75 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1,4 +1,4 @@ -import { advancedMoritaniLeaderSkillsProfile, advancedNativeLeaderSkillsProfile, classicTechLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, ordinaryLeaderSkillModeSupported } from './leader-skill-profile'; +import { advancedMoritaniLeaderSkillsProfile, advancedNativeLeaderSkillsProfile, classicTechLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, 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'; @@ -7845,8 +7845,8 @@ function bankerIncomeProfile(g: Game): boolean { new Set(g.players.map(p => p.faction)).size === g.players.length && ((!g.expansions.length && g.players.every(p => FACTIONS.some(f => f.id === p.faction && f.expansion === 'base'))) || nativeExpansionLeaderSkillsProfile(g)) && - !g.nexusCards && !g.homeworlds && (!g.techTokens || classicTechLeaderSkillsProfile(g) || nativeTechLeaderSkillsProfile(g)) && - !g.strongholdCards && !g.discoveryEnabled && !g.discoveries && !g.discoveryStash && + !g.nexusCards && !g.homeworlds && (!g.techTokens || classicTechLeaderSkillsProfile(g) || nativeTechLeaderSkillsProfile(g) || strongholdLeaderSkillsProfile(g)) && + (!g.strongholdCards || strongholdLeaderSkillsProfile(g)) && !g.discoveryEnabled && !g.discoveries && !g.discoveryStash && !g.greatMaker && !g.ecazTreachery && !g.semutaPreview && !g.mentatQuestionPreview && ((!g.moritaniAssassinatePreview && !g.moritaniAssassinate) || advancedMoritaniLeaderSkillsProfile(g)) && !g.advancedPreview && !g.kullPreview && !g.nexusKullPreview && !g.guildBetrayalPreview && !g.richeseBetrayalPreview && @@ -9393,7 +9393,7 @@ export function initializeLeaderSkillsGameForAudit(state: Game): Game { export function initializeSpiceBankerIncomeGameForAudit(state: Game): Game { requireRule(bankerIncomeProfile(state) && !state.leaderSkills && !state.spiceBankerIncomePreview && state.spiceBankerIncome === undefined, - 'Spice Banker income requires a fresh classic or supported native lobby with Leader Skills alone.'); + 'Spice Banker income requires a fresh classic or supported native skill lobby without unsupported optional modules.'); requireFreshSetup(state, state.expansions.length > 0); const g = structuredClone(state); g.leaderSkills = createLeaderSkills(random); @@ -9420,10 +9420,14 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f homeworldShipmentIntegrity(state); const g = structuredClone(state); requireFreshSetup(g, homeworlds || nexus || ix || factions); - const skillTech = leaderSkills && (classicTechLeaderSkillsProfile(g) || nativeTechLeaderSkillsProfile(g)); + const skillStronghold = leaderSkills && strongholdLeaderSkillsProfile(g); + const skillTech = leaderSkills && (classicTechLeaderSkillsProfile(g) || nativeTechLeaderSkillsProfile(g) || skillStronghold); requireRule(!g.techTokens || (skillTech && JSON.stringify(g.techTokens) === JSON.stringify(createTechTokens())), 'Leader Skills with Tech Tokens requires a fresh supported classic/native three-through-six-seat lobby with unused tokens.'); + requireRule(!g.strongholdCards || strongholdFactions || (skillStronghold && + JSON.stringify(g.strongholdCards) === JSON.stringify(createStrongholdCards())), + 'Leader Skills with Stronghold Cards requires a fresh supported Advanced lobby with unused cards.'); requireRule( (homeworlds || nexus || ix || choam || factions || g.expansions.length === 0) && (choam || factions || !g.expansions.includes('choam')) && @@ -9433,7 +9437,7 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f (nexus || !g.nexusCards) && (ecazTreachery || !g.ecazTreachery) && (!g.techTokens || skillTech) && - (strongholdFactions || !g.strongholdCards) && + (strongholdFactions || skillStronghold || !g.strongholdCards) && (homeworlds || !g.homeworlds) && g.players.every((p) => FACTIONS.some( diff --git a/game/leader-skill-profile.ts b/game/leader-skill-profile.ts index 32583954..cf4d3530 100644 --- a/game/leader-skill-profile.ts +++ b/game/leader-skill-profile.ts @@ -29,14 +29,16 @@ export function noOtherLeaderSkillModules(game: LeaderSkillProfile): boolean { !game.discoveryEnabled && !game.strongholdCards && !game.techTokens && !game.ecazTreachery; } -/** E1/E2 native skills may add Tech alone; each native predicate retains its original roster/decks. */ +/** E1/E2 native skills may add Tech and Advanced Strongholds; original roster/decks remain. */ function nativeLeaderSkillModulesSupported(game: LeaderSkillProfile): boolean { if (noOtherLeaderSkillModules(game)) return true; const players = game.players; - if (!game.techTokens || !players || players.length < 3 || players.length > 6 || + if ((!game.techTokens && !game.strongholdCards) || !players || + players.length < (game.techTokens ? 3 : 2) || players.length > 6 || + (game.strongholdCards && game.advanced !== true) || game.expansions.length < 1 || game.expansions.length > 2 || game.homeworlds || game.nexusCards || game.discoveries || game.discoveryEnabled || - game.strongholdCards || game.ecazTreachery || game.semutaPreview || + game.ecazTreachery || game.semutaPreview || game.moritaniAssassinatePreview || game.advancedPreview || game.kullPreview || game.nexusKullPreview || game.guildBetrayalPreview || game.richeseBetrayalPreview || game.nexusIxianReplacementPreview || game.nexusIxianBetrayalPreview || @@ -69,6 +71,28 @@ export function classicTechLeaderSkillsProfile(game: LeaderSkillProfile): boolea return true; } +/** Original Advanced Strongholds, with optional Tech, for classic or supported E1/E2 skills. */ +export function strongholdLeaderSkillsProfile(game: LeaderSkillProfile): boolean { + if (!game.strongholdCards || game.advanced !== true) return false; + if (game.expansions.length) return advancedNativeLeaderSkillsProfile(game); + const players = game.players; + if (!players || players.length < (game.techTokens ? 3 : 2) || players.length > 6 || + game.homeworlds || game.nexusCards || game.discoveries || game.discoveryEnabled || + game.ecazTreachery || game.semutaPreview || game.moritaniAssassinatePreview || + game.advancedPreview || game.kullPreview || game.nexusKullPreview || + game.guildBetrayalPreview || game.richeseBetrayalPreview || + game.nexusIxianReplacementPreview || game.nexusIxianBetrayalPreview || + game.nexusHarkonnenBetrayalPreview) return false; + for (let i = 0; i < players.length; i++) { + for (let j = 0; j < i; j++) if (players[j].faction === players[i].faction) return false; + let classic = false; + for (const faction of FACTIONS) + if (faction.id === players[i].faction && faction.expansion === 'base') { classic = true; break; } + if (!classic) return false; + } + return true; +} + /** Bounded integration of the ordinary Moritani roster, not Advanced assassination. */ export function basicMoritaniLeaderSkillsProfile(game: LeaderSkillProfile): boolean { return game.advanced === false && game.expansions.length === 1 && @@ -233,5 +257,5 @@ export function nativeTechLeaderSkillsProfile(game: LeaderSkillProfile): boolean export function ordinaryLeaderSkillModeSupported(game: LeaderSkillProfile): boolean { return (noOtherLeaderSkillModules(game) && (!game.expansions.length || nativeExpansionLeaderSkillsProfile(game))) || - classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game); + classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game) || strongholdLeaderSkillsProfile(game); } diff --git a/game/mentat-question.ts b/game/mentat-question.ts index cfeee28d..1de023e8 100644 --- a/game/mentat-question.ts +++ b/game/mentat-question.ts @@ -1,7 +1,7 @@ import { treacheryDeck, type Card } from './cards'; import { richeseCards } from './richese-cards'; import { quoteTupileIntelligenceAnswer } from './tupile-intelligence-answer'; -import {classicTechLeaderSkillsProfile,nativeExpansionLeaderSkillsProfile,nativeTechLeaderSkillsProfile,noOtherLeaderSkillModules,type LeaderSkillProfile} from './leader-skill-profile'; +import {classicTechLeaderSkillsProfile,nativeExpansionLeaderSkillsProfile,nativeTechLeaderSkillsProfile,noOtherLeaderSkillModules,strongholdLeaderSkillsProfile,type LeaderSkillProfile} from './leader-skill-profile'; export class MentatQuestionError extends Error {} export const MENTAT_EMPTY_HAND = @@ -43,7 +43,7 @@ export function mentatQuestionModeSupported(game: LeaderSkillProfile): boolean { return (noOtherLeaderSkillModules(game) && (!game.expansions.length || (game.expansions.length === 1 && game.expansions[0] === 'choam') || - nativeExpansionLeaderSkillsProfile(game))) || classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game); + nativeExpansionLeaderSkillsProfile(game))) || classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game) || strongholdLeaderSkillsProfile(game); } function inventory(expansions: readonly string[]): Card[] { return [ diff --git a/game/reference.ts b/game/reference.ts index b5f9bb3a..26061df1 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -3180,6 +3180,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'The mobile card copies a currently controlled stronghold, even if another faction still holds that location’s card. Holding a card after leaving its location does not itself make that location an eligible copy. The choice is public and fixed before battle plans; a sole eligible advantage is announced automatically.', 'Arrakeen pays only for actual support and creates no personal cash. CHOAM force-payment income includes the bank contribution, subject to its normal traitor and donor exclusions. Carthag does not protect an empty or Worthless defense, stop Poison Tooth, or remove a Shield’s Lasgun explosion risk.', 'Sietch Tabr uses the opposing dial, excluding leaders and other bonuses. A single traitor victory qualifies; mutual destruction does not. Tuek pays after an ordinary loss or a single traitor outcome as well as a win; a double traitor gives neither faction spice. Habbanya also decides a tied Stone Burner comparison.', + 'Fresh Advanced local development tables may combine all fourteen Leader Skills with Stronghold Cards in classic or supported Ixian, Tleilaxu, CHOAM and Richese skill rosters, two through six players; optional Tech Tokens needs three through six. Original deck/cache and end-Mentat claims remain. Arrakeen pays bank support, not personal income; only the remaining actual payer debit qualifies separate normal Banker income. Skill rescue preserves the original support charge, then card cleanup and Tech reward finish before Face Dance. Modified/copied Diplomat defenses and active skill/Stone interpretations stay guarded; public activation, E3/other overlays and save conversion remain separate.', 'The six effects, public ownership and pre-plan choice are integrated in development games. Enable Stronghold Cards in an Advanced preview lobby. Complete Advanced compliance and every expansion interaction remain under verification.', ], example: @@ -3197,7 +3198,7 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Implementation', status: 'Partial', detail: - 'All six local effects and end-Mentat custody are integrated. Full optional-module combinations remain under review.', + 'All six local effects and end-Mentat custody are integrated. Fresh Advanced classic/supported E1/E2 skill rosters additionally compose all14 skills and optional original Tech, preserving subsidy, typed rescue, native income and reward order. Full optional-module combinations remain open.', evidence: [ 'game/stronghold-cards.ts', 'game/stronghold-battle.ts', @@ -3232,10 +3233,12 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Verification', status: 'Partial', detail: - 'Focused ownership, payment, defense, ties and income checks pass. Whole-game and all-combination acceptance remains unfinished.', + 'Dated standalone cases remain. Skills/Stronghold adds21 meaningful cases,277 affected and72 final fixture-consumer cases, types/lint/build, twelve actual programs and22 genuine paired/triple games (6672 actions/no rejection,171 JSON continuations). Human390px real subsidy/Banker/Mentat and HMS copied support/typed Suk/Tech preserve original outcomes on refresh. Full/deployed acceptance remains unfinished.', evidence: [ 'tests/stronghold-cards.test.ts', 'tests/stronghold-engine.test.ts', + 'tests/classic-skills-stronghold-runtime.test.ts', + 'tests/native-skills-stronghold-runtime.test.ts', ], }, ], @@ -3281,7 +3284,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Fremen reserve arrivals and Guild cross-planet or return shipments do not trigger Heighliners. A stopped shipment never arrives and cannot trigger it. Bene Gesserit accompanying forces do arrive from off planet and can trigger the token even after a Guild shipment.', 'A battle winner takes one tech token from the defeated faction after casualties and played-card decisions. With several available tokens, the winner chooses. A winner still takes the token if no winning forces survive. Mutual destruction has no winner and transfers no token.', 'Owning all three tokens counts as one additional stronghold at victory checks. An alliance cannot combine separate token holdings to qualify: one member must own the complete set. Multiple sides reaching the victory target can share the win.', - 'Token ownership and pending income are public and persist with the room. The fresh classic Leader Skills composition retains original three-through-six-seat Basic/Advanced setup and effects. Skill rescue or board-spice awards finish before the original mandatory token reward; normal Banker remains a separate source-to-Mentat opt-in. Two-player token setup, native factions, other optional modules and full combined acceptance remain unfinished.', + 'Token ownership and pending income are public and persist with the room. Fresh supported classic or E1/E2 native Leader Skills tables retain original three-through-six-seat Basic/Advanced setup and effects; Advanced may also retain Stronghold Cards. Skill rescue or board-spice awards finish before the mandatory original winner token reward and later Face Dance. Banker remains a separate source-to-Mentat opt-in. CHOAM’s separate native opening payout is not ordinary poverty Charity. Two-player tokens, E3/other combinations and full combined acceptance remain unfinished.', ], example: 'You own Heighliners and Axlotl Tanks. When Emperor ships from reserves, place two spice on Heighliners. A later shipment adds nothing. Collect the two spice when Shipping and Movement ends.', @@ -4590,6 +4593,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Basic Moritani retains base opponents including Harkonnen. Its separate bounded Advanced classic/non-Harkonnen roster now composes the original assassination preview and all fourteen skills: winner Suk rescue and Rihani choice finish first, then an eligible different trained disc may be assassinated with its exact card return and printed bounty. No casualties repeat; original set-aside and one private Mentat replacement remain. See Moritani with Leader Skills for the exact scope.', 'Fresh Advanced profiles compose native Ixians, Tleilaxu, CHOAM and/or Richese with classic opponents and distinct required family decks. All fourteen skills and native starts remain. Auditor stays unskilled; cyborg casualties, original CHOAM support and Richese capped marker revelation remain physical. Foreign revival creates no new skill. Native Ixian Technology on Richese lots, own mixed No-Field, captured replacement, Advanced Atreides Suk/KH and unrelated overlays retain their guards.', 'Fresh three-through-six-seat Basic/Advanced development tables may compose all fourteen Leader Skills with the three original Tech Tokens: classic factions or the already-supported native Ixian, Tleilaxu, CHOAM and Richese skill rosters with their original required decks. Starting cards, cache, native skill/Traitor choices and printed or first-Storm token assignment stay genuine. Existing leader-skills and banker-income local entry preserve unused selected tokens; E3, other overlays, public activation and saved-game conversion remain separate.', + 'Fresh Advanced classic or supported E1/E2 native two-through-six-seat skill tables may also retain all six original Stronghold Cards, with optional Tech at three or more seats. Original end-Mentat claims and retained local advantages remain. Arrakeen changes actual payer cost, not physical rescue or normal Banker eligibility; bank-funded support still counts in native CHOAM income. Played Carthag defenses and normal disciplines, holder-only ties and opposing-dial or played-Worthless earnings use original outcome rules. Public activation, E3/other overlays, modified Diplomat defenses and active skill/Stone interpretations stay separate.', 'With this optional module, starting Treachery Cards are dealt after prediction and before skill assignment. Every faction privately receives two skills, keeps one, returns the other to the shuffled deck, and assigns its chosen card to an eligible leader. Skill and leader become public before traitor selection.', 'Before Battle Plans, leave the skilled leader face up to keep its normal effect, or move it behind the shield for possible use. A face-up leader cannot be selected for the plan. Concealing it while choosing another leader gives neither effect. A leader that must be used because no alternative remains moves behind the shield automatically.', 'Warmaster, Master of Assassins, Swordmaster of Ginaz, Killer Medic and Prana-Bindu Adept are connected. They give another leader +1 for the matching physical card role, or the surviving assigned leader +3. These bonuses do not stack to +4. They change battle strength, not the printed disc value or leader bounty.', @@ -4617,7 +4621,7 @@ export const RULE_TOPICS: RuleTopic[] = [ { area:'Player controls',status:'Partial',detail:'Private card/leader selection, readable inspectors, public battle posture and revival draw/decline, Planetologist range/gather controls, Special weapon selection and Suk Graduate physical rescue choices are connected. Rihani has private history and separate keep-new/reveal-old controls; automatic battle effects need no confirmation. Smuggler has explicit ordinary shipping opt-out and No-Field companion opt-in with physical-versus-priced force counts, plus automatic surviving-leader battle collection with reveal-time guidance. Banker exposes separate sealed spending and authorized inspection. Sandmaster exposes explicit legal routes, per-territory collection choices and an optional worm-destination checkbox. Diplomat offers a named committed Worthless choice or decline after public reveal, and a separate surviving-loser destination/normal/elite retreat or decline. Bureaucrat offers redirect or full payment with explicit recipient amounts and deferred bribe custody. Remaining effect controls are missing.',evidence:['components/leader-skills.tsx','components/planetologist-movement.tsx','components/suk-graduate.tsx','components/rihani-decipherer.tsx','components/mentat-question.tsx','components/bureaucrat-payment.tsx','components/leader-skill-battle-guide.tsx','components/smuggler-shipment.tsx','components/spice-banker.tsx','components/sandmaster-movement.tsx','components/sandmaster-worm.tsx','components/diplomat-defense.tsx','components/diplomat-retreat.tsx','components/game-table.tsx'] }, { area:'AI',status:'Partial',detail:'All four profiles use private offers, legal visibility/revival actions and Planetologist movement/battle candidates and legal Suk Graduate rescues, Rihani exchanges, shared battle scoring, Smuggler reserve/owned No-Field shipments and unmodified collection plans, Sandmaster route and worm collection, funded Banker commitments and optional Diplomat copying from public revealed defenses or choosing legal losing-battle retreat. Bureaucrat uses the public payee alliance to redirect enemy income or preserve allied income. These are legal prototype paths; complete skill strategy and difficulty calibration remain unfinished.',evidence:['game/bots.ts'] }, { area:'Documentation',status:'Partial',detail:'All fourteen faces and common rules are recorded. Skilled capture follows public physical-card custody; replacement entitlement and other material timing interpretations remain explicit.',evidence:['docs/LEADER_SKILLS_RULES.md','docs/LEADER_SKILLS_CAPTURE.md','docs/LEADER_SKILLS_RUNTIME.md','docs/PLANETOLOGIST_RULES.md','docs/SUK_GRADUATE_RULES.md','docs/LEADER_BATTLE_EFFECTS.md','docs/MENTAT_QUESTION.md','docs/BUREAUCRAT_PAYMENTS.md','docs/SMUGGLER_SHIPMENT.md','docs/SPICE_BANKER_RUNTIME.md','docs/SANDMASTER_WORM.md','docs/DIPLOMAT_DEFENSE.md'] }, - { area:'Verification',status:'Partial',detail:'Dated classic Skills/Tech evidence:25 cases,146 affected cases, eight genuine Basic/Advanced3..6 games (2712 actions,69 JSON continuations) and human Smuggler/payment/Mentat/Suk/two-token reward. Native E1/E2 adds52 meaningful cases,261 passing shared cases plus the corrected exact obsolete rejection, types/lint/build, nine actual programs and seventeen genuine3-seat games (fifteen native,5733 accepted actions/no rejection,149 JSON continuations). Human390px mixed cyborg Suk rescue, original Tech before real-source Face Dance, and Richese marker5/free companion/price1 use original controls and preserve outcomes on refresh. Full combinations, assurance, public and deployed acceptance stay open.',evidence:['tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/leader-battle-effects.test.ts','tests/suk-graduate.test.ts','tests/mentat-question.test.ts','tests/bureaucrat-payment.test.ts','tests/smuggler-battle.test.ts','tests/spice-banker.test.ts','tests/advanced-native-skills.test.ts','tests/fixture-advanced-native-skills.ts','tests/skills-tech-payments-runtime.test.ts','tests/skills-tech-battle-runtime.test.ts','tests/ix-choam-skills-tech-runtime.test.ts','tests/tleilaxu-skills-tech-runtime.test.ts','tests/richese-skills-tech-runtime.test.ts','docs/LEADER_SKILLS_RUNTIME.md'] }, + { area:'Verification',status:'Partial',detail:'Dated classic Skills/Tech evidence:25 cases,146 affected, eight genuine games (2712 actions,69 JSON continuations) and human payment/Mentat/Suk/token choice. Native Skills/Tech:52 meaningful cases, seventeen genuine3-seat games (5733 actions/no rejection,149 JSON continuations) and human typed rescue/FaceDance/marker companion. Advanced Skills/Stronghold adds21 meaningful cases,277 affected,72 final fixture-consumer cases, types/lint/build, twelve actual programs and22 genuine paired/triple games (6672 actions/no rejection,171 JSON continuations). Human390px actual bank2/own4 with trained rescue and Banker12→13 at Mentat, plus HMS declared bank1/own0/typed Suk/sole Axlotl, preserve original outcomes on refresh. Full combinations, assurance, public and deployed acceptance stay open.',evidence:['tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/leader-battle-effects.test.ts','tests/suk-graduate.test.ts','tests/mentat-question.test.ts','tests/bureaucrat-payment.test.ts','tests/smuggler-battle.test.ts','tests/spice-banker.test.ts','tests/advanced-native-skills.test.ts','tests/fixture-advanced-native-skills.ts','tests/skills-tech-payments-runtime.test.ts','tests/skills-tech-battle-runtime.test.ts','tests/ix-choam-skills-tech-runtime.test.ts','tests/tleilaxu-skills-tech-runtime.test.ts','tests/richese-skills-tech-runtime.test.ts','tests/classic-skills-stronghold-runtime.test.ts','tests/native-skills-stronghold-runtime.test.ts','docs/LEADER_SKILLS_RUNTIME.md'] }, ], }, { diff --git a/tests/classic-skills-stronghold-runtime.test.ts b/tests/classic-skills-stronghold-runtime.test.ts new file mode 100644 index 00000000..dfa76b32 --- /dev/null +++ b/tests/classic-skills-stronghold-runtime.test.ts @@ -0,0 +1,295 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { treacheryDeck } from '../game/cards'; +import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards'; +import { strongholdControllers } from '../game/stronghold-cards'; +import { ownedTech, TECH_TOKENS } from '../game/tech-tokens'; +import type { BattleResolutionQuote } from '../game/battle-resolution-quote'; +import type { SukRescueOption } from '../game/suk-graduate'; +import { + classicSkillsStrongholdPlayer as player, createClassicSkillsStrongholdFixture, + finishClassicSkillsStrongholdBattle, nextClassicSkillsStrongholdStep, + prepareClassicSkillsStrongholdBattle, quoteClassicSkillsStrongholdBattle, + revealClassicSkillsStrongholdBattle, type ClassicSkillsStrongholdFixture, +} from './fixture-classic-skills-stronghold'; + +function custody(game: Game): void { + const cards = [...game.deck, ...game.discard, ...(game.auction?.cards.slice(game.auction.index) ?? []), ...game.players.flatMap(seat => seat.hand)]; + assert.deepEqual(cards.map(card => card.id).sort(), treacheryDeck([]).map(card => card.id).sort()); + const skills = game.leaderSkills!; + assert.deepEqual([...skills.deck, ...skills.assignments.map(assignment => assignment.skill), + ...Object.values(skills.offers).flatMap(offer => offer.cards)].sort(), LEADER_SKILL_CARDS.map(card => card.id).sort()); + for (const seat of game.players) { + assert.equal(seat.reserves + seat.tanks + Object.values(seat.forces).reduce((sum, count) => sum + count, 0), 20); + if (seat.elites) { + const total = seat.faction === 'fremen' ? 3 : 5; + assert.equal(seat.elites.reserves + seat.elites.tanks + Object.values(seat.elites.forces).reduce((sum, count) => sum + count, 0), total); + for (const [key, count] of Object.entries(seat.elites.forces)) assert.ok(count <= (seat.forces[key] ?? 0)); + } + } +} +function bonus(quote: BattleResolutionQuote, source: Game, actor: string) { + return source.battle!.attacker === actor ? quote.leaderSkillBonuses.attacker : quote.leaderSkillBonuses.defender; +} +function moneyBeforeCleanup(fixture: ClassicSkillsStrongholdFixture, revealed: Game, cleanup: Game) { + assert.equal(cleanup.decision?.kind, 'battleCards'); + const quote = quoteClassicSkillsStrongholdBattle(revealed); + for (const seat of revealed.players) { + const debit = quote.payments.filter(payment => payment.player === seat.id).reduce((sum, payment) => sum + payment.ownPayment, 0); + const income = quote.strongholdIncome.filter(receipt => receipt.player === seat.id).reduce((sum, receipt) => sum + receipt.amount, 0); + const bounty = quote.bounty?.player === seat.id ? quote.bounty.amount : 0; + assert.equal(player(cleanup, seat.id).spice, seat.spice - debit + income + bounty, + 'Actual own debit, Stronghold bank income and original killed-leader bounty settle before Collection'); + } + assert.equal(cleanup.lastBattleContext!.winner, quote.winner); + assert.deepEqual(cleanup.strongholdCards, fixture.afterFirstMentat.strongholdCards, + 'Battle casualties do not steal retained cards before the next END Mentat'); + custody(cleanup); + return quote; +} +function reject(game: Game, actor: string, action: Action): void { + const before = structuredClone(game); + assert.throws(() => applyAction(game, actor, action)); + assert.deepEqual(game, before); +} + +void test('original Advanced classic two-through-six-seat inventories survive real setup and first END Mentat custody without redeal', () => { + for (let count = 2; count <= 6; count++) { + const fixture = createClassicSkillsStrongholdFixture({ seatIds: Array.from({ length: count }, (_, index) => `classic-${count}-${index}`), optionalTech: count >= 3 }); + custody(fixture.initial); custody(fixture.afterSetup); custody(fixture.beforeBattle); + const settled = applyAction(fixture.beforeFirstMentat, fixture.firstMentatStep.actor, fixture.firstMentatStep.action); + assert.equal(settled.turn, 2); + assert.equal(settled.strongholdCards!.owners.arrakeen, fixture.owner); + assert.deepEqual(settled.strongholdCards!.owners, strongholdControllers(fixture.beforeFirstMentat.players, false)); + assert.deepEqual(settled.strongholdCards, fixture.afterFirstMentat.strongholdCards); + assert.deepEqual(fixture.beforeBattle.players.map(seat => seat.id), fixture.initial.players.map(seat => seat.id)); + if (count >= 3) { + assert.deepEqual(TECH_TOKENS.map(token => fixture.beforeBattle.techTokens![token.id].owner), + TECH_TOKENS.map(token => fixture.afterFirstMentat.techTokens![token.id].owner)); + assert.ok(TECH_TOKENS.every(token => fixture.beforeBattle.players.some(seat => seat.id === fixture.beforeBattle.techTokens![token.id].owner))); + } + } +}); + +void test('a fresh authenticated original lobby retains human IDs and input state while composing Strongholds, all fourteen Skills and optional Tech', () => { + let lobby = createGame('AUTHCLASSIC', newPlayer('human-emperor', 'Emperor', 'emperor'), true); + joinGame(lobby, newPlayer('human-guild', 'Guild', 'guild')); + joinGame(lobby, newPlayer('human-atreides', 'Atreides', 'atreides')); + lobby = applyAction(lobby, lobby.host, { type: 'strongholdCards', enabled: true }); + lobby = applyAction(lobby, lobby.host, { type: 'techTokens', enabled: true }); + const original = structuredClone(lobby); + const fixture = createClassicSkillsStrongholdFixture({ initial: lobby, optionalTech: true, bankerIncome: true, ownerDial: 6, support: 6 }); + assert.deepEqual(lobby, original); + assert.deepEqual(fixture.initial.players.map(seat => seat.id), original.players.map(seat => seat.id)); + assert.equal(fixture.owner, 'human-guild'); + assert.equal(fixture.banker, 'human-atreides'); + assert.equal(fixture.afterFirstMentat.strongholdCards!.owners.arrakeen, 'human-guild'); + const rescue = finishClassicSkillsStrongholdBattle(revealClassicSkillsStrongholdBattle(fixture), { stop: 'skill' }); + assert.equal(rescue.decision?.kind, 'sukRescue'); + assert.deepEqual(viewGame(rescue, 'human-atreides').spiceBankerIncome!.deferred, [{ owner: 'human-atreides', amount: 1 }]); + custody(rescue); +}); + +for (const scenario of [ + { support: 0, dial: 4, own: 0, casualties: 8 }, + { support: 2, dial: 4, own: 0, casualties: 6 }, + { support: 5, dial: 6, own: 3, casualties: 7 }, + { support: 6, dial: 6, own: 4, casualties: 6 }, +]) { + void test(`Arrakeen original support ${scenario.support}: actual own ${scenario.own}, not bank-funded support, controls Banker while trained Suk rescues physical losses`, () => { + const fixture = createClassicSkillsStrongholdFixture({ bankerIncome: true, skill: 'suk-graduate', band: 'skilled', + ownerDial: scenario.dial, support: scenario.support, ownerWeapon: 'projectile', ownerDefense: 'shield', opponentWeapon: 'projectile' }); + const revealed = revealClassicSkillsStrongholdBattle(fixture); + const quote = quoteClassicSkillsStrongholdBattle(revealed); + const payment = quote.payments.find(receipt => receipt.player === fixture.owner)!; + assert.equal(payment.bankSupport, Math.min(2, scenario.support)); + assert.equal(payment.ownPayment, scenario.own); + assert.equal(quote.casualties!.options[0].normal, scenario.casualties); + assert.equal(quote.sukGraduate?.mode, 'skilled'); + const before = player(revealed, fixture.owner); + const bankerBalance = player(revealed, fixture.banker!).spice; + let game = finishClassicSkillsStrongholdBattle(revealed, { stop: 'skill' }); + const decision = game.decision; assert.ok(decision?.kind === 'sukRescue'); + assert.equal(player(game, fixture.owner).spice, before.spice - scenario.own + 3, + 'The bank share is not spendable currency and rescue does not refund originally committed support'); + assert.equal(player(game, fixture.banker!).spice, bankerBalance); + assert.deepEqual(viewGame(game, fixture.banker!).spiceBankerIncome!.deferred, + scenario.own === 4 ? [{ owner: fixture.banker!, amount: 1 }] : []); + const source = game.spiceBankerIncome!.sources.find(receipt => receipt.source.kind === 'battle-support' && receipt.source.bankLegs.some(leg => leg.payer === fixture.owner)); + if (scenario.own) assert.deepEqual(source!.source.bankLegs, [{ payer: fixture.owner, amount: scenario.own }]); + else assert.equal(source, undefined, 'Zero actual payer debit produces no qualifying bank source'); + const choice = decision.options.findIndex(option => option.normal === 3 && option.elite === 0 && option.kept?.key === fixture.key); + assert.ok(choice >= 0); + const action: Action = { type: 'decision', event: decision.event, choice }; + reject(game, fixture.opponent, action); + reject(game, fixture.owner, { ...action, event: `${decision.event}-old` }); + game = applyAction(game, fixture.owner, action); + assert.equal(player(game, fixture.owner).forces[fixture.key], 8 - scenario.casualties + 1); + assert.equal(player(game, fixture.owner).reserves, before.reserves + 2); + assert.equal(player(game, fixture.owner).tanks, before.tanks + scenario.casualties - 3); + assert.equal(player(game, fixture.owner).battleLosses, before.battleLosses + scenario.casualties - 3); + moneyBeforeCleanup(fixture, revealed, game); + reject(game, fixture.owner, action); + }); +} + +void test('four real policies physically choose original Suk rescue before winner-card cleanup and the actual losing-owner Tech reward', () => { + const fixture = createClassicSkillsStrongholdFixture({ optionalTech: true, bankerIncome: true, skill: 'suk-graduate', band: 'skilled', + ownerDial: 6, support: 6, ownerWeapon: 'projectile', ownerDefense: 'shield', opponentWeapon: 'projectile' }); + const revealed = revealClassicSkillsStrongholdBattle(fixture); + const originalTokens = ownedTech(revealed.techTokens, fixture.opponent); + assert.equal(originalTokens.length, 1, 'Original three-seat assignment supplies a real losing-owner token'); + const originalOwner = player(revealed, fixture.owner); + const originalEnemy = player(revealed, fixture.opponent); + const window = finishClassicSkillsStrongholdBattle(revealed, { stop: 'skill' }); + assert.deepEqual(window.techTokens, revealed.techTokens); + assert.equal(player(window, fixture.opponent).tanks, originalEnemy.tanks + 8); + assert.equal(player(window, fixture.opponent).leaders.find(leader => leader.id === fixture.planActions[1].action.leader)!.dead, true); + assert.ok(window.discard.some(card => card.id === fixture.planActions[1].action.weapon), 'Loser played-card cleanup precedes winner rescue'); + assert.ok(window.decision?.kind === 'sukRescue'); + for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) { + const view = viewGame(window, fixture.owner); + view.players.find(seat => seat.id === fixture.owner)!.bot = difficulty; + const action = botActions(view).find(candidate => candidate.type === 'decision'); + assert.ok(action && typeof action.choice === 'number'); + const rescue: SukRescueOption = window.decision.options[action.choice]; + assert.equal(rescue.normal, 3); assert.equal(rescue.elite, 0); assert.equal(rescue.kept?.key, fixture.key); + const cleanup = applyAction(window, fixture.owner, action); + assert.equal(player(cleanup, fixture.owner).forces[fixture.key], 3); + assert.equal(player(cleanup, fixture.owner).reserves, originalOwner.reserves + 2); + assert.equal(player(cleanup, fixture.owner).tanks, originalOwner.tanks + 3); + assert.equal(player(cleanup, fixture.owner).spice, originalOwner.spice - 4 + 3); + assert.equal(cleanup.decision?.kind, 'battleCards'); + assert.deepEqual(cleanup.techTokens, revealed.techTokens, 'The original reward waits for actual winner cleanup'); + const resolved = finishClassicSkillsStrongholdBattle(cleanup); + assert.equal(resolved.techTokens![originalTokens[0]].owner, fixture.owner); + assert.deepEqual(ownedTech(resolved.techTokens, fixture.opponent), []); + assert.deepEqual(TECH_TOKENS.filter(token => resolved.techTokens![token.id].owner !== revealed.techTokens![token.id].owner).map(token => token.id), originalTokens); + assert.equal(resolved.lastBattleContext!.winner, fixture.owner); + assert.equal(resolved.lastBattleContext!.sukRescue!.completed, true); + custody(resolved); + } +}); + +void test('Carthag actual non-poison Shield saves the normal Prana Bindu-trained disc, whose matching physical defense bonus decides the win', () => { + const fixture = createClassicSkillsStrongholdFixture({ kind: 'carthag', skill: 'prana-bindu-adept', band: 'normal', + ownerDefense: 'shield', opponentWeapon: 'poison', ownerDial: 0.5, opponentDial: 1, opponentSupport: 1 }); + const revealed = revealClassicSkillsStrongholdBattle(fixture); + const cleanup = finishClassicSkillsStrongholdBattle(revealed, { stop: 'cleanup' }); + const quote = moneyBeforeCleanup(fixture, revealed, cleanup); + assert.deepEqual(bonus(quote, revealed, fixture.owner), { bonus: 1, applied: [{ skill: 'prana-bindu-adept', amount: 1, mode: 'normal' }] }); + assert.equal(quote.effects!.defenderDead, false); + assert.equal(quote.leaderDeaths.defender, false); + assert.equal(player(cleanup, fixture.owner).leaders.find(leader => leader.id === fixture.planActions[0].action.leader)!.dead, false); + assert.equal(quote.winner, fixture.owner); + assert.equal(quote.scores!.defender, 4.5); + assert.equal(quote.scores!.attacker, 4); + const unclaimed = createClassicSkillsStrongholdFixture({ kind: 'carthag', cardHolder: 'unclaimed', skill: 'prana-bindu-adept', band: 'normal', + ownerDefense: 'shield', opponentWeapon: 'poison', ownerDial: 0.5, opponentDial: 1, opponentSupport: 1 }); + const poisoned = finishClassicSkillsStrongholdBattle(revealClassicSkillsStrongholdBattle(unclaimed), { stop: 'cleanup' }); + assert.equal(player(poisoned, unclaimed.owner).leaders.find(leader => leader.id === unclaimed.planActions[0].action.leader)!.dead, true); + assert.equal(poisoned.lastBattleContext!.winner, unclaimed.opponent); +}); + +void test('Habbanya holder-only priority resolves the tie created by the actual normal Warmaster Worthless modifier, never a lower score', () => { + for (const holder of ['owner', 'unclaimed'] as const) { + const fixture = createClassicSkillsStrongholdFixture({ kind: 'habbanya_ridge_sietch', cardHolder: holder, + skill: 'warmaster', band: 'normal', ownerWeapon: 'worthless', ownerDefense: 'shield', opponentDefense: 'shield', opponentDial: 1 }); + const revealed = revealClassicSkillsStrongholdBattle(fixture); + assert.equal(revealed.battle!.defender, fixture.owner); + const cleanup = finishClassicSkillsStrongholdBattle(revealed, { stop: 'cleanup' }); + const quote = moneyBeforeCleanup(fixture, revealed, cleanup); + assert.equal(bonus(quote, revealed, fixture.owner).bonus, 1); + assert.deepEqual(quote.scores, { attacker: 4, defender: 4 }); + assert.equal(quote.winner, holder === 'owner' ? fixture.owner : fixture.opponent); + if (holder === 'owner') { + let lower = prepareClassicSkillsStrongholdBattle(fixture); + lower = applyAction(lower, fixture.owner, { ...fixture.planActions[0].action, weapon: null }); + lower = applyAction(lower, fixture.opponent, fixture.planActions[1].action); + const loser = finishClassicSkillsStrongholdBattle(lower, { stop: 'cleanup' }); + assert.equal(loser.lastBattleContext!.winner, fixture.opponent, 'Retained Habbanya custody does not turn a Warmaster-less lower total into a tie'); + } + } +}); + +void test('Sietch Tabr pays the actual opposing declared dial only after the surviving skilled Mentat bonus makes its holder the winner', () => { + const fixture = createClassicSkillsStrongholdFixture({ kind: 'sietch_tabr', skill: 'mentat', band: 'skilled', + ownerDial: 0.5, opponentDial: 2, opponentSupport: 2, opponentLeaderStrength: 5, ownerDefense: 'shield', opponentDefense: 'shield' }); + const revealed = revealClassicSkillsStrongholdBattle(fixture); + const cleanup = finishClassicSkillsStrongholdBattle(revealed, { stop: 'cleanup' }); + const quote = moneyBeforeCleanup(fixture, revealed, cleanup); + assert.deepEqual(bonus(quote, revealed, fixture.owner), { bonus: 2, applied: [{ skill: 'mentat', amount: 2, mode: 'skilled' }] }); + assert.deepEqual(quote.scores, { attacker: 7, defender: 7.5 }); + assert.equal(quote.winner, fixture.owner); + assert.deepEqual(quote.strongholdIncome, [{ player: fixture.owner, amount: 2 }]); + let normal = prepareClassicSkillsStrongholdBattle(fixture); + const otherDisc = player(normal, fixture.owner).leaders.find(leader => leader.strength === 3)!; + normal = applyAction(normal, fixture.owner, { ...fixture.planActions[0].action, leader: otherDisc.id }); + normal = applyAction(normal, fixture.opponent, fixture.planActions[1].action); + const lost = finishClassicSkillsStrongholdBattle(normal, { stop: 'cleanup' }); + assert.equal(lost.lastBattleContext!.winner, fixture.opponent); + assert.equal(player(lost, fixture.owner).spice, player(normal, fixture.owner).spice, + 'The holder gets no Tabr income on the actual loss without its trained Mentat total'); +}); + +void test('Tuek pays each actually played Worthless card on both Warmaster-modified wins and losses, but mutual traitors exclude income and comparison', () => { + for (const losing of [false, true]) { + const fixture = createClassicSkillsStrongholdFixture({ kind: 'tueks_sietch', skill: 'warmaster', band: 'normal', + ownerWeapon: 'worthless', ownerDefense: losing ? 'worthless' : 'shield', opponentDefense: 'shield', + opponentDial: losing ? 2 : 0, opponentSupport: losing ? 2 : 0 }); + const revealed = revealClassicSkillsStrongholdBattle(fixture); + const cleanup = finishClassicSkillsStrongholdBattle(revealed, { stop: 'cleanup' }); + const quote = moneyBeforeCleanup(fixture, revealed, cleanup); + assert.deepEqual(bonus(quote, revealed, fixture.owner), { bonus: 1, applied: [{ skill: 'warmaster', amount: 1, mode: 'normal' }] }); + assert.equal(quote.winner, losing ? fixture.opponent : fixture.owner); + assert.deepEqual(quote.strongholdIncome, [{ player: fixture.owner, amount: losing ? 4 : 2 }]); + if (losing) for (const slot of ['weapon', 'defense'] as const) + assert.ok(cleanup.discard.some(card => card.id === fixture.planActions[0].action[slot]), 'Loss physically discards each paid Worthless card'); + } + const mutual = createClassicSkillsStrongholdFixture({ kind: 'tueks_sietch', skill: 'warmaster', band: 'normal', + ownerWeapon: 'worthless', ownerDefense: 'worthless', traitors: 'both', ownerDial: 1, support: 1 }); + const revealed = revealClassicSkillsStrongholdBattle(mutual); + const ended = finishClassicSkillsStrongholdBattle(revealed, { callers: [mutual.owner, mutual.opponent] }); + assert.equal(ended.lastBattleContext!.winner, null); + assert.equal(player(ended, mutual.owner).spice, player(revealed, mutual.owner).spice - Number(mutual.planActions[0].action.support), + 'Mutual traitors retain the original support debit and earn neither played-Worthless income nor killed-leader bounty'); + assert.equal(player(ended, mutual.owner).forces[mutual.key] ?? 0, 0); + assert.equal(player(ended, mutual.opponent).forces[mutual.key] ?? 0, 0); + for (const slot of ['weapon', 'defense'] as const) + assert.ok(ended.discard.some(card => card.id === mutual.planActions[0].action[slot])); + custody(ended); +}); + +void test('four legal own-seat policies seal an affordable original Arrakeen plan without spending the bank contribution or a future Suk/Tech reward', () => { + const fixture = createClassicSkillsStrongholdFixture({ kind: 'arrakeen', skill: 'suk-graduate', band: 'skilled', optionalTech: true }); + let game = prepareClassicSkillsStrongholdBattle(fixture); + const gift = Math.max(1, player(game, fixture.owner).spice - 5); + game = applyAction(game, fixture.owner, { type: 'bribe', target: fixture.opponent, amount: gift }); + const balance = player(game, fixture.owner).spice; + reject(game, fixture.owner, { ...fixture.planActions[0].action, dial: 8, support: 8 }); + for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) { + const view = viewGame(game, fixture.owner); + view.players.find(seat => seat.id === fixture.owner)!.bot = difficulty; + const action = botActions(view).find(candidate => candidate.type === 'battlePlan'); + assert.ok(action); + const sealed = applyAction(game, fixture.owner, action); + assert.equal(player(sealed, fixture.owner).spice, balance, 'Native plans commit support but debit only on resolution'); + assert.deepEqual(sealed.techTokens, game.techTokens); + let revealed = sealed; + while (revealed.response || revealed.phaseOpening || revealed.decision?.kind === 'fullPlanOffer') { + const next = nextClassicSkillsStrongholdStep(revealed); assert.ok(next); + revealed = applyAction(revealed, next.actor, next.action); + } + revealed = applyAction(revealed, fixture.opponent, fixture.planActions[1].action); + const quote = quoteClassicSkillsStrongholdBattle(revealed); + const payment = quote.payments.find(receipt => receipt.player === fixture.owner)!; + assert.equal(payment.bankSupport, Math.min(2, Number(action.support ?? 0))); + assert.equal(payment.ownPayment, Math.max(0, Number(action.support ?? 0) - 2)); + assert.ok(payment.ownPayment <= balance); + const ended = finishClassicSkillsStrongholdBattle(revealed); + assert.equal(ended.lastBattleContext!.winner, quote.winner); + custody(ended); + } +}); diff --git a/tests/fixture-classic-skills-stronghold.ts b/tests/fixture-classic-skills-stronghold.ts new file mode 100644 index 00000000..4e5e72fd --- /dev/null +++ b/tests/fixture-classic-skills-stronghold.ts @@ -0,0 +1,373 @@ +import assert from 'node:assert/strict'; +import { + applyAction, createGame, handLimit, initializeLeaderSkillsGameForAudit, + initializeSpiceBankerIncomeGameForAudit, 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 { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards'; +import { leaderSkillStrongholdCount } from '../game/leader-skill-battle-board'; +import { createStrongholdCards, strongholdControllers, type StrongholdId } from '../game/stronghold-cards'; +import { createTechTokens } from '../game/tech-tokens'; +import { quoteBattleResolution, type ResolutionCombatant } from '../game/battle-resolution-quote'; +import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions'; + +export type ClassicSkillsStrongholdStep = { actor: string; action: Action }; +export type ClassicSkillsStrongholdOptions = { + /** Fresh authenticated Advanced classic lobby; original IDs/order survive. */ + initial?: Game; + seatIds?: string[]; + optionalTech?: boolean; + bankerIncome?: boolean; + kind?: Exclude; + skill?: LeaderSkillId; + band?: 'normal' | 'skilled'; + ownerFaction?: FactionId; + opponentFaction?: FactionId; + cardHolder?: 'owner' | 'opponent' | 'unclaimed'; + ownerDial?: number; + support?: number; + opponentDial?: number; + opponentSupport?: number; + ownerLeaderStrength?: number; + opponentLeaderStrength?: number; + ownerWeapon?: Card['kind']; + ownerDefense?: Card['kind']; + opponentWeapon?: Card['kind']; + opponentDefense?: Card['kind']; + traitors?: 'owner' | 'both'; +}; +export type ClassicSkillsStrongholdFixture = { + initial: Game; + afterSetup: Game; + beforeFirstMentat: Game; + firstMentatStep: ClassicSkillsStrongholdStep; + afterFirstMentat: Game; + beforeBattle: Game; + battleAction: Action; + game: Game; + actor: string; + owner: string; + opponent: string; + banker?: string; + trainer: string; + kind: StrongholdId; + key: string; + skill: LeaderSkillId; + band: 'normal' | 'skilled'; + planActions: ClassicSkillsStrongholdStep[]; + staging: string[]; +}; + +export function classicSkillsStrongholdPlayer(game: Game, id: string): Player { + const player = game.players.find(p => p.id === id); + assert.ok(player, `Missing original classic seat ${id}`); + return player; +} + +/** Control only the original fourteen-card shuffle, before any native deal. */ +function withOffers(selected: LeaderSkillId[], initialize: () => T): T { + assert.equal(new Set(selected).size, selected.length); + const working: LeaderSkillId[] = LEADER_SKILL_CARDS.map(card => card.id); + const remainder = working.filter(skill => !selected.includes(skill)); + const desired = selected.flatMap(skill => [skill, remainder.shift()!]); + desired.push(...remainder); + const rolls: number[] = []; + for (let 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 native = 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(native, crypto, [array]); + return array; + }; + try { return initialize(); } + finally { + if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); + else Reflect.deleteProperty(crypto, 'getRandomValues'); + } +} + +/** The existing native policy owns readiness and projected response passes. + * In particular, a phase opening must close before a response can be passed. */ +export function nextClassicSkillsStrongholdStep(game: Game): ClassicSkillsStrongholdStep | null { + if (!game.phaseOpening && !game.response) { + const decision = game.decision; + if (decision?.kind === 'leaderSkillVisibility') + return { actor: decision.player, action: { type: 'leaderSkillVisibility', event: decision.event, hide: false } }; + if (decision?.kind === 'mentatQuestion') + return { actor: decision.player, action: { type: 'decision', event: decision.event, decline: true } }; + if (decision?.kind === 'techToken') + return { actor: decision.player, action: { type: 'decision', token: decision.choices[0] } }; + if (decision?.kind === 'sukRescue') { + const choice = decision.options.map((option, index) => ({ index, saved: option.normal + option.elite, kept: !!option.kept })) + .sort((a, b) => b.saved - a.saved || Number(b.kept) - Number(a.kept))[0].index; + return { actor: decision.player, action: { type: 'decision', event: decision.event, choice } }; + } + } + return nextStrongholdFactionsNativeStep(game); +} +function step(game: Game): Game { + const next = nextClassicSkillsStrongholdStep(game); + assert.ok(next, 'Native continuation stopped at an unsealed human plan'); + return applyAction(game, next.actor, next.action); +} +const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision; +function advance(game: Game, predicate: (state: Game) => boolean): Game { + for (let count = 0; count < 1800; count++) { + if (predicate(game)) return game; + assert.equal(game.status, 'playing'); + game = step(game); + } + throw Error('Original classic continuation did not reach its requested boundary'); +} + +function reserveBoard(game: Game): void { + for (const player of game.players) { + player.reserves += Object.values(player.forces).reduce((sum, count) => sum + count, 0); + player.forces = {}; + if (player.elites) { + player.elites.reserves += Object.values(player.elites.forces).reduce((sum, count) => sum + count, 0); + player.elites.forces = {}; + } + if (player.advisors) player.advisors = {}; + } +} +function place(game: Game, actor: string, key: string, count: number): void { + const player = classicSkillsStrongholdPlayer(game, actor); + assert.ok(player.reserves - (player.elites?.reserves ?? 0) >= count, 'Stage ordinary physical counters, not hidden elite counters'); + player.reserves -= count; + player.forces[key] = (player.forces[key] ?? 0) + count; +} +function heldCard(game: Game, actor: string, matches: (card: Card) => boolean, staging: string[], used: string[] = []): Card { + const player = classicSkillsStrongholdPlayer(game, actor); + const held = player.hand.find(card => matches(card) && !used.includes(card.id)); + if (held) return held; + const index = game.deck.findIndex(card => matches(card) && !used.includes(card.id)); + const donor = index < 0 ? game.players.find(p => p.id !== actor && p.hand.some(card => matches(card) && !used.includes(card.id))) : undefined; + assert.ok(index >= 0 || donor, 'Required original card must retain actual deck/hand custody'); + assert.ok(player.hand.length < handLimit(player)); + const card = index >= 0 ? game.deck.splice(index, 1)[0] + : donor!.hand.splice(donor!.hand.findIndex(card => matches(card) && !used.includes(card.id)), 1)[0]; + player.hand.push(card); + staging.push(`Conserved ${card.id}: native ${donor?.id ?? 'deck'} custody → ${actor}'s hand`); + return card; +} +function conserveTraitor(game: Game, actor: string, identity: string, staging: string[]): void { + const player = classicSkillsStrongholdPlayer(game, actor); + if (player.traitors.includes(identity)) return; + const returned = player.traitors[0]; + const index = game.traitorReserve?.indexOf(identity) ?? -1; + const donor = game.players.find(p => p.id !== actor && p.traitors.includes(identity)); + assert.ok(returned && (index >= 0 || donor), 'Conserved original traitor must remain in native custody'); + if (index >= 0) game.traitorReserve!.splice(index, 1, returned); + else donor!.traitors[donor!.traitors.indexOf(identity)] = returned; + player.traitors[0] = identity; + staging.push(`Conserved traitor ${identity}: native ${donor?.id ?? 'reserve'} custody exchanged for ${actor}'s ${returned}`); +} + +/** Real original setup and first END Mentat acquisition. Labeled staging only + * conserves physical board/cards and orders unplayed Spice Cards. No phase, + * turn, wallet, Stronghold/Tech owner, plan, quote or earned receipt is assigned. */ +export function createClassicSkillsStrongholdFixture(options: ClassicSkillsStrongholdOptions = {}): ClassicSkillsStrongholdFixture { + const skill = options.skill ?? 'suk-graduate'; + const band = options.band ?? 'skilled'; + const kind = options.kind ?? 'arrakeen'; + const staging: string[] = []; + let game: Game; + if (options.initial) game = structuredClone(options.initial); + else { + const ids = options.seatIds ?? ['emperor', 'guild', 'atreides']; + assert.ok(ids.length >= 2 && ids.length <= 6 && new Set(ids).size === ids.length); + const factions: FactionId[] = ['emperor', 'guild', 'atreides', 'fremen', 'harkonnen', 'beneGesserit']; + game = createGame('CLASSICSKILLSSTRONGHOLD', newPlayer(ids[0], factions[0], factions[0]), true); + for (let index = 1; index < ids.length; index++) joinGame(game, newPlayer(ids[index], factions[index], factions[index])); + } + assert.equal(game.status, 'lobby', 'Only the original fresh authenticated undealt lobby is accepted'); + assert.equal(game.advanced, true); + assert.deepEqual(game.expansions, []); + assert.ok(game.players.length >= 2 && game.players.length <= 6); + const owner = game.players.find(p => p.faction === (options.ownerFaction ?? 'guild'))?.id; + const opponent = game.players.find(p => p.faction === (options.opponentFaction ?? 'emperor'))?.id; + assert.ok(owner && opponent && owner !== opponent); + const banker = options.bankerIncome ? game.players.find(p => p.faction === 'atreides')?.id : undefined; + if (options.bankerIncome) assert.ok(banker && banker !== owner && banker !== opponent && game.players.length >= 3); + if (!game.strongholdCards) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: true }); + assert.deepEqual(game.strongholdCards, createStrongholdCards()); + if (options.optionalTech) { + assert.ok(game.players.length >= 3); + if (!game.techTokens) game = applyAction(game, game.host, { type: 'techTokens', enabled: true }); + } + if (game.techTokens) assert.deepEqual(game.techTokens, createTechTokens()); + for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' }); + const passive: LeaderSkillId[] = (['sandmaster', 'smuggler', 'rihani-decipherer', 'prana-bindu-adept', 'killer-medic', 'master-of-assassins'] as const) + .filter(candidate => candidate !== skill); + const selected = game.players.map(player => player.id === owner ? skill : player.id === banker ? 'spice-banker' as const : passive.shift()!); + game = withOffers(selected, () => options.bankerIncome + ? initializeSpiceBankerIncomeGameForAudit(game) : initializeLeaderSkillsGameForAudit(game)); + const initial = structuredClone(game); + for (let count = 0; game.status === 'setup' && count < 200; count++) { + if (game.setupStage === 'leaderSkills') { + const actor = Object.keys(game.leaderSkills!.offers)[0]; + const offer = game.leaderSkills!.offers[actor]; + const wanted = selected[game.players.findIndex(player => player.id === actor)]; + const view = viewGame(game, actor).leaderSkills!; + assert.ok(offer.cards.includes(wanted) && !view.unavailableSkills?.[wanted]); + const eligible = new Set(view.eligibleLeaders.map(leader => leader.id)); + const leader = classicSkillsStrongholdPlayer(game, actor).leaders.filter(leader => eligible.has(leader.id)) + .sort((a, b) => b.strength - a.strength)[0]; + assert.ok(leader); + game = applyAction(game, actor, { type: 'leaderSkill', event: offer.event, skill: wanted, leader: leader.id }); + } else game = step(game); + } + assert.equal(game.status, 'playing'); + const afterSetup = structuredClone(game); + // Four conserved unplayed non-worm blows keep the two-turn suffix out of Nexus. + const blows = game.spiceDeck.filter(card => 'territory' in card).slice(0, 4); + for (let index = 0; index < blows.length; index++) { + const from = game.spiceDeck.indexOf(blows[index]); + game.spiceDeck.splice(index, 0, game.spiceDeck.splice(from, 1)[0]); + } + staging.push('Controlled original unplayed Spice Card order: four non-worm blows; not a natural shuffle history'); + game = advance(game, state => state.phase === 8 && clean(state)); + reserveBoard(game); + const key = `${kind}:${territory(kind).sectors[0]}`; + const holder = options.cardHolder === 'opponent' ? opponent : options.cardHolder === 'unclaimed' ? undefined : owner; + if (holder) place(game, holder, key, 1); + staging.push('Before genuine first END Mentat: conserved board counters through their own reserves; only the labeled card claimant occupies one stronghold'); + let beforeFirstMentat: Game | undefined; + let firstMentatStep: ClassicSkillsStrongholdStep | undefined; + for (let count = 0; game.turn === 1 && count < 200; count++) { + const next = nextClassicSkillsStrongholdStep(game); assert.ok(next); + const before = structuredClone(game); + game = applyAction(game, next.actor, next.action); + if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = next; } + } + assert.ok(beforeFirstMentat && firstMentatStep); + assert.equal(game.turn, 2); + assert.equal(game.strongholdCards!.owners[kind], holder ?? null); + assert.deepEqual(game.strongholdCards!.owners, strongholdControllers(beforeFirstMentat.players, false)); + const afterFirstMentat = structuredClone(game); + // Use the actual physical Weather Control card rather than fabricate a storm. + // It also makes physical attacker/defender deterministic from retained circles. + const weather = heldCard(game, owner, card => card.effect === 'weather', staging); + game = advance(game, state => state.phase === 0 && clean(state)); + game = applyAction(game, owner, { type: 'card', card: weather.id, amount: 0 }); + game = advance(game, state => state.phase === 5 && clean(state)); + reserveBoard(game); + for (const actor of [owner, opponent]) place(game, actor, key, 8); + staging.push('Before genuine turn-two movement completion: conserve eight ordinary counters per combatant; retained first-Mentat card custody is unchanged'); + assert.ok(!territory(kind).sectors.includes(game.storm), 'Original storm must leave the battle territory available'); + const used: string[] = []; + const card = (actor: string, face: Card['kind'] | undefined): string | null => { + if (!face) return null; + const held = heldCard(game, actor, physical => physical.kind === face, staging, used); + used.push(held.id); + return held.id; + }; + const ownerWeapon = card(owner, options.ownerWeapon); + const ownerDefense = card(owner, options.ownerDefense); + const opponentWeapon = card(opponent, options.opponentWeapon); + const opponentDefense = card(opponent, options.opponentDefense); + const trainer = game.leaderSkills!.assignments.find(assignment => assignment.owner === owner)!.leader; + const untrained = (actor: string, strength: number | undefined) => { + const candidates = classicSkillsStrongholdPlayer(game, actor).leaders.filter(leader => !leader.dead && !leader.usedAt && + !game.leaderSkills!.assignments.some(assignment => assignment.leader === leader.id)); + const leader = strength === undefined ? candidates.find(leader => leader.strength === 3) ?? candidates[0] : candidates.find(leader => leader.strength === strength); + assert.ok(leader, 'Requested original untrained printed disc must exist'); + return leader.id; + }; + const ownerLeader = band === 'skilled' ? trainer : untrained(owner, options.ownerLeaderStrength); + const opponentLeader = untrained(opponent, options.opponentLeaderStrength); + if (options.traitors) { + conserveTraitor(game, owner, opponentLeader, staging); + if (options.traitors === 'both') conserveTraitor(game, opponent, ownerLeader, staging); + } + game = advance(game, state => state.phase === 6 && clean(state)); + const beforeBattle = structuredClone(game); + const actor = game.active!; + assert.ok(actor === owner || actor === opponent); + const battleAction: Action = { type: 'chooseBattle', territory: kind, target: actor === owner ? opponent : owner }; + game = applyAction(game, actor, battleAction); + const planActions: ClassicSkillsStrongholdStep[] = [ + { actor: owner, action: { type: 'battlePlan', dial: options.ownerDial ?? 0, support: options.support ?? 0, leader: ownerLeader, weapon: ownerWeapon, defense: ownerDefense } }, + { actor: opponent, action: { type: 'battlePlan', dial: options.opponentDial ?? 0, support: options.opponentSupport ?? 0, leader: opponentLeader, weapon: opponentWeapon, defense: opponentDefense } }, + ]; + return { initial, afterSetup, beforeFirstMentat, firstMentatStep, afterFirstMentat, beforeBattle, battleAction, game, actor, owner, opponent, banker, trainer, kind, key, skill, band, planActions, staging }; +} + +/** Human UI can stop at the original pre-plan state; runtime proof deliberately + * passes native windows and chooses the requested legal normal/skilled band. */ +export function prepareClassicSkillsStrongholdBattle(fixture: ClassicSkillsStrongholdFixture, state = fixture.game): Game { + let game = structuredClone(state); + for (let count = 0; count < 160; count++) { + const next = nextClassicSkillsStrongholdStep(game); + if (!next) { assert.ok(game.battle && !game.battle.revealed); return game; } + if (next.action.type === 'leaderSkillVisibility') next.action.hide = next.actor === fixture.owner && fixture.band === 'skilled'; + game = applyAction(game, next.actor, next.action); + } + throw Error('Original classic plan boundary did not open'); +} +export function revealClassicSkillsStrongholdBattle(fixture: ClassicSkillsStrongholdFixture): Game { + let game = prepareClassicSkillsStrongholdBattle(fixture); + for (const plan of fixture.planActions) { + game = applyAction(game, plan.actor, plan.action); + while (game.response || game.phaseOpening || game.decision?.kind === 'fullPlanOffer') game = step(game); + } + assert.ok(game.battle?.revealed); + return game; +} +export function finishClassicSkillsStrongholdBattle(state: Game, options: { stop?: 'skill' | 'cleanup' | 'tech'; callers?: string[] } = {}): Game { + let game = structuredClone(state); + for (let count = 0; count < 240; count++) { + if (options.stop === 'skill' && game.decision?.kind === 'sukRescue') return game; + if (options.stop === 'cleanup' && game.decision?.kind === 'battleCards') return game; + if (options.stop === 'tech' && game.decision?.kind === 'techToken') return game; + if (!game.battle && clean(game) && !game.pendingTreacheryDiscard) return game; + const next = nextClassicSkillsStrongholdStep(game); assert.ok(next); + if (next.action.type === 'traitorCall') next.action.call = options.callers?.includes(next.actor) ?? false; + game = applyAction(game, next.actor, next.action); + } + throw Error('Original classic skill/Stronghold battle suffix did not finish'); +} + +/** Quote the actual revealed source, with both native physical support and + * assigned skill bands. Do not use the standalone Stronghold quote adapter, + * which intentionally has no Leader Skill inputs. */ +export function quoteClassicSkillsStrongholdBattle(game: Game) { + const battle = game.battle; assert.ok(battle?.revealed); + const combatant = (actor: string): ResolutionCombatant => { + const player = classicSkillsStrongholdPlayer(game, actor); + const view = viewGame(game, actor).battle!; + assert.ok(view.ownForces); + return { + id: actor, faction: player.faction, ally: player.ally, spice: player.spice, hand: player.hand, + plan: battle.plans[actor], leader: player.leaders.find(leader => leader.id === battle.plans[actor].leader), + forces: view.ownForces, stronghold: view.strongholdEffects[actor], + poisonTooth: battle.poisonTooth?.[actor], lateDefense: battle.lateDefense?.[actor], stoneMode: battle.stoneBurner?.[actor], + leaderSkills: game.leaderSkills!.assignments.filter(assignment => assignment.owner === actor) + .map(assignment => ({ skill: assignment.skill, leader: assignment.leader, faceUp: !battle.leaderSkillHidden?.[actor], captured: false })), + occupiedStrongholds: leaderSkillStrongholdCount(game, player), + }; + }; + return quoteBattleResolution({ + advanced: game.advanced, turn: game.turn, territory: battle.territory, + aggressor: viewGame(game, game.host).battle!.aggressor, + attacker: combatant(battle.attacker), defender: combatant(battle.defender), + voters: viewGame(game, game.host).battle!.traitorVoters.map(actor => ({ id: actor, + beneficiary: [battle.attacker, battle.defender].includes(actor) ? actor : classicSkillsStrongholdPlayer(game, actor).ally!, + called: battle.traitorCalls[actor] ?? false, traitors: classicSkillsStrongholdPlayer(game, actor).traitors })), + participants: game.players, physicalCards: [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand)], + pendingAuditorPresent: false, pendingRetentionPresent: false, + }); +} diff --git a/tests/fixture-native-skills-stronghold.ts b/tests/fixture-native-skills-stronghold.ts new file mode 100644 index 00000000..2f44dcee --- /dev/null +++ b/tests/fixture-native-skills-stronghold.ts @@ -0,0 +1,351 @@ +import assert from 'node:assert/strict'; +import { applyAction, createGame, handLimit, joinGame, newPlayer, viewGame, type Action, type Game, type Player } from '../game/engine'; +import { MOBILE_LOCATION, MOBILE_STRONGHOLD, territory } from '../game/board'; +import { isAuditorLeader, type Card } from '../game/cards'; +import type { FactionId } from '../game/catalog'; +import { validateLeaderSkills } from '../game/leader-skills'; +import { strongholdControllers, type StrongholdId } from '../game/stronghold-cards'; +import { initializeAdvancedNativeSkillsSetup, type AdvancedNativeSkillsOptions } from './fixture-advanced-native-skills'; +import { nextStrongholdFactionsNativeStep, quoteStrongholdFactionsBattle, type StrongholdFactionsNativeStep } from './fixture-stronghold-factions'; +import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds'; + +export type NativeSkillsStrongholdCase = 'ix-copy-suk' | 'choam-support' | 'choam-auditor' | + 'tleilaxu-tabr' | 'tleilaxu-tuek' | 'richese-marker' | 'richese-stone'; +export type NativeSkillsStrongholdOptions = { + /** A fresh authenticated Advanced lobby; retain IDs, order and selected native decks. */ + initial?: Game; + kind?: NativeSkillsStrongholdCase; + optionalTech?: boolean; +}; +export type NativeSkillsStrongholdFixture = { + initial: Game; + offered: Game; + afterSetup: Game; + beforeFirstMentat: Game; + firstMentatStep: StrongholdFactionsNativeStep; + afterFirstMentat: Game; + beforeBattle: Game; + game: Game; + kind: NativeSkillsStrongholdCase; + owner: string; + opponent: string; + choam?: string; + tleilaxu?: string; + trainer: string; + territory: StrongholdId; + location: string; + boardSource: string; + copyAction: Action | null; + plans: StrongholdFactionsNativeStep[]; + acquisition: { before: Game; after: Game; steps: StrongholdFactionsNativeStep[] } | null; + shipment: { before: Game; after: Game; step: StrongholdFactionsNativeStep } | null; + actions: StrongholdFactionsNativeStep[]; + staging: string[]; +}; + +export function nativeSkillsStrongholdPlayer(game: Game, id: string): Player { + const player = game.players.find(p => p.id === id); + assert.ok(player, `Missing original native seat ${id}`); + return player; +} +export function assertNativeSkillsStrongholdCustody(game: Game): void { + validateLeaderSkills(game.leaderSkills!, game.players); + for (const p of game.players) { + assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0), 20, p.faction); + if (p.elites) { + const inventory = p.faction === 'ixians' ? 7 : p.faction === 'fremen' ? 3 : 5; + assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((a, b) => a + b, 0), inventory, p.faction); + for (const [key, count] of Object.entries(p.elites.forces)) assert.ok(count <= (p.forces[key] ?? 0)); + } + } + const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand), + ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? [])]; + assert.equal(new Set(cards.map(c => c.id)).size, cards.length, 'Canonical treachery/cache cards retain single physical custody'); +} +const clean = (g: Game) => !g.phaseOpening && !g.response && !g.decision; +const key = (id: StrongholdId) => id === MOBILE_STRONGHOLD ? MOBILE_LOCATION : `${id}:${territory(id).sectors[0]}`; + +/** Existing native policies, with deliberate no-call/no-spend skill choices. + * Stop before human plans and HMS declarations; never fabricate a window. */ +export function nextNativeSkillsStrongholdStep(game: Game): StrongholdFactionsNativeStep | null { + if (game.decision?.kind === 'richeseCache') { + const card = game.richeseCache!.find(c => c.id !== 'richese-stone-burner'); assert.ok(card); + return { actor: game.decision.player, action: { type: 'decision', event: game.richeseBidding!.event, + card: card.id, method: 'onceAround', direction: 'counterclockwise' } }; + } + if (game.decision?.kind === 'richeseUnbid') return { actor: game.decision.player, + action: { type: 'decision', event: game.richeseBidding!.event, keep: false } }; + if (game.decision?.kind === 'leaderSkillVisibility') return { actor: game.decision.player, + action: { type: 'leaderSkillVisibility', event: game.decision.event, hide: true } }; + if (game.decision?.kind === 'mentatQuestion') return { actor: game.decision.player, + action: { type: 'decision', event: game.decision.event, decline: true } }; + if (game.decision?.kind === 'rihani') return { actor: game.decision.player, + action: { type: 'decision', event: game.decision.event, draw: false } }; + if (game.decision?.kind === 'battleLosses') { + const decision = game.decision; + const elite = Math.max(...decision.options.map(option => option.elite)); + return { actor: decision.player, action: { type: 'decision', + choice: decision.options.findIndex(option => option.elite === elite) } }; + } + if (game.decision?.kind === 'sukRescue') { + const decision = game.decision; + const maximum = Math.max(...decision.options.map(o => o.normal + o.elite)); + const choice = decision.options.findIndex(o => o.normal + o.elite === maximum && o.kept?.kind === 'normal'); + return { actor: decision.player, action: { type: 'decision', event: decision.event, + choice: choice >= 0 ? choice : decision.options.findIndex(o => o.normal + o.elite === maximum) } }; + } + if (game.decision?.kind === 'techToken') return { actor: game.decision.player, + action: { type: 'decision', token: game.decision.choices[0] } }; + if (game.decision?.kind === 'strongholdCopy' || game.decision?.kind === 'faceDance') return null; + return game.players.some(p => p.faction === 'richese') + ? nextRicheseStrongholdsNativeStep(game) : nextStrongholdFactionsNativeStep(game); +} +function act(game: Game, next: StrongholdFactionsNativeStep, actions?: StrongholdFactionsNativeStep[]): Game { + actions?.push(structuredClone(next)); + return applyAction(game, next.actor, next.action); +} +export function advanceNativeSkillsStronghold(state: Game, until: (g: Game) => boolean, + actions?: StrongholdFactionsNativeStep[], firstDial = 0): Game { + let game = state; + for (let i = 0; i < 1800; i++) { + if (until(game)) return game; + assert.equal(game.status, 'playing'); + const next = nextNativeSkillsStrongholdStep(game); + assert.ok(next, `Real human choice required at ${game.decision?.kind ?? 'battle plans'}`); + if (game.turn === 1 && next.action.type === 'stormDial') next.action.amount = firstDial; + game = act(game, next, actions); + } + throw Error('Native Skills/Stronghold continuation did not reach its requested boundary'); +} +function reserveBoard(game: Game): void { + 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 = {}; + } +} +function place(game: Game, actor: string, location: string, count: number, elite = 0): void { + const p = nativeSkillsStrongholdPlayer(game, actor); + assert.ok(p.reserves >= count && count >= elite && (p.elites?.reserves ?? 0) >= elite); + p.reserves -= count; p.forces[location] = (p.forces[location] ?? 0) + count; + if (elite) { p.elites!.reserves -= elite; p.elites!.forces[location] = (p.elites!.forces[location] ?? 0) + elite; } +} +function held(game: Game, actor: string, predicate: (c: Card) => boolean, staging: string[], excluded: string[] = []): string { + const p = nativeSkillsStrongholdPlayer(game, actor); + const owned = p.hand.find(c => predicate(c) && !excluded.includes(c.id)); + if (owned) return owned.id; + const index = game.deck.findIndex(c => predicate(c) && !excluded.includes(c.id)); + assert.ok(index >= 0 && p.hand.length < handLimit(p), 'A canonical native card and real hand capacity are required'); + const card = game.deck.splice(index, 1)[0]; p.hand.push(card); + staging.push(`Conserved ${card.id}: remaining native deck → ${actor}'s hand`); + return card.id; +} +function untrained(game: Game, id: string, strongest = false): string { + const p = nativeSkillsStrongholdPlayer(game, id); + const trained = new Set(game.leaderSkills!.assignments.map(a => a.leader)); + const leader = p.leaders.filter(l => !l.dead && !isAuditorLeader(l) && !trained.has(l.id)) + .sort((a, b) => strongest ? b.strength - a.strength : a.strength - b.strength)[0]; + assert.ok(leader); return leader.id; +} +/** Same conserved Traitor/Face Dancer exchange as the native Skills/Tech fixture. + * Label this rule position explicitly; never claim it was the random draw. */ +function matchingDancer(game: Game, actor: string, leader: string, staging: string[]): void { + const dancer = nativeSkillsStrongholdPlayer(game, actor).faceDancers!.find(c => !c.revealed); + assert.ok(dancer); + if (nativeSkillsStrongholdPlayer(game, actor).faceDancers!.some(c => c.leader === leader && !c.revealed)) return; + 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, 'Matching physical Traitor has a native custodian'); + donor.traitors[donor.traitors.indexOf(leader)] = dancer.leader; + } + staging.push(`Conserved matching Face Dancer ${leader}: native Traitor custody exchanged with ${dancer.leader}`); + dancer.leader = leader; +} + +/** Genuine Advanced native setup, first Storm (optional original Tech assignment), + * and actual first END Mentat. Only initial physical skill offers, unplayed Spice + * Card order and labeled conserved board/card positions are controlled. Wallets, + * phases, turns, owners, quotes and earned receipts are never assigned. */ +export function createNativeSkillsStrongholdFixture(options: NativeSkillsStrongholdOptions = {}): NativeSkillsStrongholdFixture { + const kind = options.kind ?? 'ix-copy-suk'; + const ix = kind === 'ix-copy-suk'; + const tleilaxuCase = kind === 'tleilaxu-tabr' || kind === 'tleilaxu-tuek'; + const richese = kind === 'richese-marker' || kind === 'richese-stone'; + const family = ix ? 'ixians' : tleilaxuCase ? 'tleilaxu' : richese ? 'richese' : 'choam'; + const ownerFaction: FactionId = tleilaxuCase || kind === 'choam-support' ? 'guild' : family; + const factions: FactionId[] = ix ? ['ixians', 'emperor', 'choam', 'fremen'] : + tleilaxuCase ? ['guild', 'tleilaxu', 'emperor'] : richese ? ['richese', 'choam', 'guild'] : + kind === 'choam-support' ? ['guild', 'choam', 'emperor'] : ['choam', 'guild', 'emperor']; + const decks = ix ? ['ix', 'choam'] : [tleilaxuCase ? 'ix' : 'choam']; + let game = options.initial ? structuredClone(options.initial) + : createGame('NATIVESKILLSSTRONGHOLD', newPlayer(factions[0], factions[0], factions[0]), true, decks); + if (!options.initial) for (const faction of factions.slice(1)) joinGame(game, newPlayer(faction, faction, faction)); + assert.equal(game.status, 'lobby', 'Continue only a fresh authenticated undealt lobby'); + assert.equal(game.advanced, true); + assert.ok(game.players.length >= 2 && game.players.length <= 6); + assert.ok(game.expansions.every(deck => deck === 'ix' || deck === 'choam')); + const owner = game.players.find(p => p.faction === ownerFaction)?.id; + const opponent = game.players.find(p => p.faction === (tleilaxuCase ? 'tleilaxu' : richese ? 'guild' : + ix || kind === 'choam-support' ? 'emperor' : 'guild'))?.id; + assert.ok(owner && opponent); + const choam = game.players.find(p => p.faction === 'choam')?.id; + const tleilaxu = game.players.find(p => p.faction === 'tleilaxu')?.id; + if (!game.strongholdCards) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: true }); + if (options.optionalTech && !game.techTokens) game = applyAction(game, game.host, { type: 'techTokens', enabled: true }); + if (game.techTokens) assert.ok(game.players.length >= 3); + const initial = structuredClone(game), actions: StrongholdFactionsNativeStep[] = [], staging: string[] = []; + const native: AdvancedNativeSkillsOptions = { family, skillOwner: ownerFaction, requestedSkill: 'suk-graduate', initial: game }; + game = initializeAdvancedNativeSkillsSetup(native); + const offered = structuredClone(game); + for (let i = 0; game.status === 'setup' && i < 200; i++) { + if (game.setupStage === 'leaderSkills') { + const actor = Object.keys(game.leaderSkills!.offers)[0]; + const offer = game.leaderSkills!.offers[actor], view = viewGame(game, actor).leaderSkills!; + const skill = actor === owner ? 'suk-graduate' : offer.cards.find(c => !view.unavailableSkills?.[c]); + const eligible = new Set(view.eligibleLeaders.map(l => l.id)); + const leader = nativeSkillsStrongholdPlayer(game, actor).leaders.filter(l => eligible.has(l.id)).sort((a, b) => b.strength - a.strength)[0]; + assert.ok(skill && offer.cards.includes(skill) && leader); + game = act(game, { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: leader.id } }, actions); + } else { + const next = nextNativeSkillsStrongholdStep(game); assert.ok(next); game = act(game, next, actions); + } + } + assert.equal(game.status, 'playing'); + const afterSetup = structuredClone(game); + const trainer = game.leaderSkills!.assignments.find(a => a.owner === owner && a.skill === 'suk-graduate')!.leader; + // Genuine non-worm cards stay in their native deck. No piles or phase receipts are injected. + const blows = game.spiceDeck.filter(c => 'territory' in c).slice(0, 6); + for (const [position, card] of blows.entries()) game.spiceDeck.splice(position, 0, game.spiceDeck.splice(game.spiceDeck.indexOf(card), 1)[0]); + staging.push('Conserved first six unplayed non-worm Spice Cards moved to the front of the native Spice Deck; not a natural shuffle history.'); + const stronghold: StrongholdId = ix ? MOBILE_STRONGHOLD : kind === 'tleilaxu-tabr' ? 'sietch_tabr' : + kind === 'tleilaxu-tuek' ? 'tueks_sietch' : kind === 'richese-stone' ? 'habbanya_ridge_sietch' : 'arrakeen'; + const location = key(stronghold), firstDial = kind === 'tleilaxu-tuek' || kind === 'richese-stone' ? 4 : 0; + let acquisition: NativeSkillsStrongholdFixture['acquisition'] = null; + if (kind === 'richese-stone') { + game = advanceNativeSkillsStronghold(game, g => g.decision?.kind === 'richeseCache', actions, firstDial); + const before = structuredClone(game), steps: StrongholdFactionsNativeStep[] = []; + for (let i = 0; !nativeSkillsStrongholdPlayer(game, owner).hand.some(c => c.id === 'richese-stone-burner') && i < 100; i++) { + const next = game.decision?.kind === 'richeseCache' ? { actor: owner, action: { + type: 'decision' as const, event: game.richeseBidding!.event, card: 'richese-stone-burner', + method: 'onceAround', direction: 'counterclockwise' } } : + game.decision?.kind === 'richeseUnbid' ? { actor: owner, action: { + type: 'decision' as const, event: game.richeseBidding!.event, keep: true } } : nextNativeSkillsStrongholdStep(game); + assert.ok(next); game = act(game, next, steps); + } + assert.ok(nativeSkillsStrongholdPlayer(game, owner).hand.some(c => c.id === 'richese-stone-burner')); + actions.push(...steps); acquisition = { before, after: structuredClone(game), steps }; + } + let shipment: NativeSkillsStrongholdFixture['shipment'] = null; + if (kind === 'richese-marker') { + game = advanceNativeSkillsStronghold(game, g => g.phase === 5 && clean(g) && g.active === owner, actions, firstDial); + reserveBoard(game); + const p = nativeSkillsStrongholdPlayer(game, owner), token = p.noField!.tokens.find(t => t.value === 5)!; + const before = structuredClone(game), step: StrongholdFactionsNativeStep = { actor: owner, + action: { type: 'ship', territory: stronghold, sector: territory(stronghold).sectors[0], noField: token.id, event: p.noFieldEvent } }; + game = act(game, step, actions); + game = advanceNativeSkillsStronghold(game, clean, actions, firstDial); + shipment = { before, after: structuredClone(game), step }; + staging.push('Actual native No-Field five shipment: no marker is assigned or materialized by the fixture.'); + } + game = advanceNativeSkillsStronghold(game, g => g.phase === 8 && clean(g), actions, firstDial); + reserveBoard(game); + if (kind !== 'richese-marker') place(game, owner, location, 1, ix ? 1 : 0); + if (ix) place(game, owner, key('arrakeen'), 1); + staging.push('Before actual first END Mentat: conserve original board counters through native reserves; owner controls only the requested Stronghold (Ix also Arrakeen), no three-hold victory.'); + let beforeFirstMentat: Game | undefined, firstMentatStep: StrongholdFactionsNativeStep | undefined; + while (game.turn === 1) { + const before = structuredClone(game), next = nextNativeSkillsStrongholdStep(game); assert.ok(next); + game = act(game, next, actions); + if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = next; } + } + assert.ok(beforeFirstMentat && firstMentatStep); + assert.equal(game.strongholdCards!.owners[stronghold], owner); + const afterFirstMentat = structuredClone(game); + game = advanceNativeSkillsStronghold(game, g => g.phase === 5 && clean(g), actions); + reserveBoard(game); + const boardSource = 'polar_sink:0'; + if (kind === 'richese-marker') { + const p = nativeSkillsStrongholdPlayer(game, owner); + assert.ok(p.noField!.deployed && p.reserves >= 3); + place(game, owner, boardSource, p.reserves - 3); + place(game, opponent, location, 6); + staging.push('Conserved marker-only battle: Richese has exactly three reserves; every other surviving Richese physical counter is in Polar Sink, not alongside the concealed marker.'); + } else { + for (const actor of [owner, opponent]) place(game, actor, location, 8, ix && actor === owner ? 2 : 0); + if (ix) { + place(game, owner, key('arrakeen'), 1); + place(game, owner, key('sietch_tabr'), 1); + } + if (tleilaxuCase) { + place(game, opponent, boardSource, 3); + matchingDancer(game, opponent, trainer, staging); + } + staging.push('Before actual turn-two Movement completion: conserve eight battle counters per combatant (Ix six Suboids/two cyborgs); keep real first-Mentat card custody and all native wallets.'); + if (ix) staging.push('The second copy-control counter (Tabr) is staged only in real turn-two Movement, after the first genuine two-hold END Mentat claim. This three-hold battle position is controlled, not natural history; no later Ix Mentat continuation or victory is suppressed.'); + } + let weapon: string | null = null, defense: string | null = null, enemyWeapon: string | null = null; + if (tleilaxuCase || kind === 'choam-auditor') weapon = held(game, owner, c => c.kind === 'projectile', staging); + if (tleilaxuCase) { + defense = held(game, owner, c => c.kind === (kind === 'tleilaxu-tuek' ? 'worthless' : 'shield'), staging, weapon ? [weapon] : []); + enemyWeapon = held(game, opponent, c => c.kind === 'worthless', staging, defense ? [defense] : []); + } + if (ix) defense = held(game, owner, c => c.kind === 'shield', staging); + if (kind === 'richese-stone') weapon = 'richese-stone-burner'; + const ownerLeader = kind === 'choam-auditor' ? nativeSkillsStrongholdPlayer(game, owner).leaders.find(isAuditorLeader)!.id : + kind === 'richese-stone' ? untrained(game, owner) : trainer; + const opponentLeader = untrained(game, opponent); + game = advanceNativeSkillsStronghold(game, g => g.phase === 6 && clean(g), actions); + const beforeBattle = structuredClone(game); + assert.ok(game.active === owner || game.active === opponent); + game = act(game, { actor: game.active!, action: { type: 'chooseBattle', territory: stronghold, target: game.active === owner ? opponent : owner } }, actions); + game = advanceNativeSkillsStronghold(game, g => g.decision?.kind === 'strongholdCopy' || + !!g.battle && clean(g) && !g.battle.preparation && g.battle.preLeader?.closed !== false, actions); + const copyAction: Action | null = game.decision?.kind === 'strongholdCopy' + ? { type: 'decision', event: game.decision.event, stronghold: 'arrakeen' } : null; + const ownerDial = ix ? 5 : kind === 'richese-marker' ? 3 : kind === 'richese-stone' ? 2 : 4; + const support = ix ? 1 : ownerDial; + const opponentDial = tleilaxuCase || kind === 'richese-stone' || kind === 'choam-auditor' ? 2 : 0; + const plans: StrongholdFactionsNativeStep[] = [ + { actor: owner, action: { type: 'battlePlan', dial: ownerDial, support, leader: ownerLeader, weapon, defense } }, + { actor: opponent, action: { type: 'battlePlan', dial: opponentDial, support: opponentDial, leader: opponentLeader, weapon: enemyWeapon } }, + ]; + assertNativeSkillsStrongholdCustody(game); + return { initial, offered, afterSetup, beforeFirstMentat, firstMentatStep, afterFirstMentat, beforeBattle, game, + kind, owner, opponent, choam, tleilaxu, trainer, territory: stronghold, location, boardSource, + copyAction, plans, acquisition, shipment, actions, staging }; +} + +export function revealNativeSkillsStrongholdBattle(fixture: NativeSkillsStrongholdFixture): Game { + let game = structuredClone(fixture.game); + if (fixture.copyAction) { + game = applyAction(game, fixture.owner, fixture.copyAction); + game = advanceNativeSkillsStronghold(game, g => !!g.battle && clean(g) && !g.battle.preparation && g.battle.preLeader?.closed !== false); + } + for (const plan of fixture.plans) { + game = applyAction(game, plan.actor, plan.action); + if (!game.battle?.revealed) game = advanceNativeSkillsStronghold(game, g => clean(g) && !g.battle?.preparation && g.battle?.preLeader?.closed !== false); + } + assert.ok(game.battle?.revealed); + return game; +} +/** Stop at consumer-visible choices; fully resolved means this battle, not a made-up phase. */ +export function settleNativeSkillsStrongholdBattle(state: Game, stop: 'suk' | 'cards' | 'faceDance' | 'complete' = 'complete'): Game { + return advanceNativeSkillsStronghold(state, g => + stop === 'suk' && g.decision?.kind === 'sukRescue' || + stop === 'cards' && g.decision?.kind === 'battleCards' || + stop === 'faceDance' && g.decision?.kind === 'faceDance' || + !g.battle && clean(g) && !g.pendingTreacheryDiscard); +} +export function finishNativeSkillsStrongholdTurn(state: Game): Game { + return advanceNativeSkillsStronghold(state, g => g.turn > state.turn); +} +export function nativeSkillsStrongholdControllers(game: Game) { + return strongholdControllers(game.players, !!game.mobileStronghold?.location); +} +/** The shared quote is authoritative for native typed forces/payment/income. + * These cases deliberately choose Suk (no strength bonus) or no active skill. */ +export const quoteNativeSkillsStrongholdBattle = quoteStrongholdFactionsBattle; diff --git a/tests/fixture-stronghold-factions.ts b/tests/fixture-stronghold-factions.ts index d6969b45..f0e34734 100644 --- a/tests/fixture-stronghold-factions.ts +++ b/tests/fixture-stronghold-factions.ts @@ -83,14 +83,10 @@ export function nextStrongholdFactionsNativeStep(game: Game): StrongholdFactions return { actor, action: { type: 'ready' } }; } if (game.response) { - // Use projected legal responses: an absent Karama does not create a pause. - for (const player of game.players) { - const view = viewGame(game, player.id); - view.players.find(p => p.id === player.id)!.bot = 'Easy'; - const action = botActions(view).find(a => a.type === 'passResponse'); - if (action) return { actor: player.id, action }; - } - throw Error(`No actual eligible counter at ${game.response.kind}`); + // The persisted pass queue defines legality; a policy recommendation is not a legal-action menu. + const actor = game.players.find(player => !game.response!.passed.includes(player.id))?.id; + assert.ok(actor, `No remaining original response at ${game.response.kind}`); + return { actor, action: { type: 'passResponse' } }; } const decision = game.decision; if (decision) { diff --git a/tests/native-skills-stronghold-runtime.test.ts b/tests/native-skills-stronghold-runtime.test.ts new file mode 100644 index 00000000..fd26bec8 --- /dev/null +++ b/tests/native-skills-stronghold-runtime.test.ts @@ -0,0 +1,303 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { FACTIONS } from '../game/catalog'; +import { treacheryDeck, isAuditorLeader } from '../game/cards'; +import { richeseCards } from '../game/richese-cards'; +import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards'; +import { ownedTech } from '../game/tech-tokens'; +import { quoteSpiceCollection } from '../game/board-resolution-quote'; +import { + assertNativeSkillsStrongholdCustody as custody, createNativeSkillsStrongholdFixture as fixture, + finishNativeSkillsStrongholdTurn, nativeSkillsStrongholdControllers, + nativeSkillsStrongholdPlayer as player, nextNativeSkillsStrongholdStep, + quoteNativeSkillsStrongholdBattle, revealNativeSkillsStrongholdBattle as reveal, + settleNativeSkillsStrongholdBattle as settle, type NativeSkillsStrongholdFixture, +} from './fixture-native-skills-stronghold'; + +/** Native Collection is an independent later receipt, not a battle subsidy or bounty. */ +function collectionIncome(game: Game, id: string): number { + if (game.phase !== 7 || game.phaseOpening) return 0; + return quoteSpiceCollection({ ...game, players: game.players.map(p => ({ ...p, spice: 0 })) }).receipts + .filter(r => r.player === id).reduce((sum, r) => sum + r.strongholds + r.collected, 0); +} +function assertBattleWallets(before: Game, after: Game): void { + const quote = quoteNativeSkillsStrongholdBattle(before); + for (const p of before.players) { + const paid = quote.payments.find(r => r.player === p.id)?.ownPayment ?? 0; + const stronghold = quote.strongholdIncome.filter(r => r.player === p.id).reduce((sum, r) => sum + r.amount, 0); + const bounty = quote.bounty?.player === p.id ? quote.bounty.amount : 0; + const choam = quote.choamIncome?.owner === p.id ? quote.choamIncome.amount : 0; + assert.equal(player(after, p.id).spice, p.spice - paid + stronghold + bounty + choam + collectionIncome(after, p.id), p.faction); + } +} +function afterCopy(f: NativeSkillsStrongholdFixture): Game { + assert.ok(f.copyAction); + let game = applyAction(f.game, f.owner, f.copyAction); + for (let i = 0; (game.decision || game.response || game.battle?.preparation || game.battle?.preLeader?.closed === false) && i < 100; i++) { + const next = nextNativeSkillsStrongholdStep(game); assert.ok(next); + game = applyAction(game, next.actor, next.action); + } + return game; +} + +void test('original Advanced native offers, wallets, decks/cache and physical 20/7/5/3 survive genuine Stronghold and optional Tech setup', () => { + for (const kind of ['ix-copy-suk', 'tleilaxu-tabr', 'richese-marker'] as const) { + for (const optionalTech of [false, true]) { + const f = fixture({ kind, optionalTech }); + const skills = f.offered.leaderSkills!; + assert.deepEqual([...skills.deck, ...Object.values(skills.offers).flatMap(o => o.cards), ...skills.assignments.map(a => a.skill)].sort(), + LEADER_SKILL_CARDS.map(c => c.id).sort()); + for (const p of f.afterSetup.players) assert.equal(p.spice, FACTIONS.find(c => c.id === p.faction)!.spice, p.faction); + const physical = [...f.afterSetup.deck, ...f.afterSetup.discard, ...f.afterSetup.players.flatMap(p => p.hand), ...(f.afterSetup.richeseCache ?? [])]; + const canonical = [...treacheryDeck(f.afterSetup.expansions), ...(kind === 'richese-marker' ? richeseCards() : [])]; + assert.deepEqual(physical.map(c => c.id).sort(), canonical.map(c => c.id).sort()); + assert.equal(nativeSkillsStrongholdControllers(f.beforeFirstMentat)[f.territory], f.owner); + assert.equal(f.beforeFirstMentat.strongholdCards!.owners[f.territory], null); + const claimed = applyAction(f.beforeFirstMentat, f.firstMentatStep.actor, f.firstMentatStep.action); + assert.equal(claimed.strongholdCards!.owners[f.territory], f.owner); + assert.equal(claimed.turn, 2); + if (optionalTech) { + const seats = new Set(f.afterFirstMentat.players.map(p => p.id)); + assert.ok(Object.values(f.afterFirstMentat.techTokens!).every(token => token.owner && seats.has(token.owner))); + if (kind !== 'richese-marker') { + const native = f.afterSetup.players.find(p => p.faction === (kind === 'ix-copy-suk' ? 'ixians' : 'tleilaxu'))!; + assert.ok(ownedTech(f.afterFirstMentat.techTokens, native.id).includes(kind === 'ix-copy-suk' ? 'heighliners' : 'axlotl')); + } + } + custody(f.afterSetup); custody(f.afterFirstMentat); custody(f.game); + } + } +}); + +void test('authenticated original lobby keeps human IDs and native decks while actually reaching HMS copy and typed trained-disc Suk', () => { + const initial = createGame('NATIVEHUMAN', newPlayer('human-ix', 'Human Ix', 'ixians'), true, ['ix', 'choam']); + joinGame(initial, newPlayer('human-emperor', 'Human Emperor', 'emperor')); + joinGame(initial, newPlayer('human-choam', 'Human CHOAM', 'choam')); + joinGame(initial, newPlayer('human-fremen', 'Human Fremen', 'fremen')); + const before = structuredClone(initial); + const f = fixture({ initial, kind: 'ix-copy-suk', optionalTech: 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'); + assert.equal(f.game.decision?.kind, 'strongholdCopy'); + assert.deepEqual(f.game.decision?.kind === 'strongholdCopy' ? [...f.game.decision.choices].sort() : [], ['arrakeen', 'sietch_tabr']); + const pending = structuredClone(f.game); + assert.throws(() => applyAction(f.game, f.owner, f.plans[0].action)); + assert.deepEqual(f.game, pending, 'No hidden plan can precede the public HMS copy declaration'); + const ready = afterCopy(f); + assert.equal(viewGame(ready, f.opponent).battle!.strongholdEffects[f.owner], 'arrakeen'); + const revealed = reveal(f), quote = quoteNativeSkillsStrongholdBattle(revealed); + assert.equal(quote.payments.find(p => p.player === f.owner)!.bankSupport, 1); + assert.equal(quote.payments.find(p => p.player === f.owner)!.ownPayment, 0); + const rescue = settle(revealed, 'suk'); + assert.equal(rescue.decision?.kind, 'sukRescue'); + if (rescue.decision?.kind !== 'sukRescue') throw Error('Native typed casualties must reach Suk'); + assert.deepEqual(rescue.pendingSukRescue!.losses, { normal: 4, elite: 2, paidNormal: 0, paidElite: 1 }); + const choice = rescue.decision.options.findIndex(o => o.normal === 2 && o.elite === 1 && o.kept?.kind === 'normal'); + assert.ok(choice >= 0); + const resolved = settle(applyAction(rescue, f.owner, { type: 'decision', event: rescue.decision.event, choice })); + assert.equal(resolved.lastBattleContext!.winner, f.owner); + assert.equal(player(resolved, f.owner).forces[f.location], 3); + assert.equal(player(resolved, f.owner).elites!.forces[f.location] ?? 0, 0); + assert.equal(player(resolved, f.owner).elites!.reserves, 6); + assert.equal(player(resolved, f.owner).elites!.tanks, 1); + assert.equal(player(resolved, f.owner).tanks, 3); + assert.equal(player(resolved, f.opponent).tanks, 8); + assertBattleWallets(revealed, resolved); custody(resolved); +}); + +void test('actual Arrakeen subsidy funds trained Suk without suppressing CHOAM original declared-support income', () => { + const f = fixture({ kind: 'choam-support', optionalTech: true }); + const revealed = reveal(f), quote = quoteNativeSkillsStrongholdBattle(revealed); + assert.equal(quote.payments.find(r => r.player === f.owner)!.ownPayment, 2); + assert.equal(quote.payments.find(r => r.player === f.owner)!.bankSupport, 2); + assert.deepEqual(quote.choamIncome, { owner: f.choam, amount: 2 }); + const resolved = settle(revealed); + assert.equal(player(resolved, f.owner).forces[f.location], 5); + assert.equal(player(resolved, f.owner).tanks, 1); + assert.equal(player(resolved, f.choam!).spice, player(revealed, f.choam!).spice + 2); + assertBattleWallets(revealed, resolved); custody(resolved); +}); + +void test('native CHOAM Auditor is excluded from training and cannot borrow a hidden trained Suk rescue', () => { + const f = fixture({ kind: 'choam-auditor' }); + const eligible = viewGame(f.offered, f.owner).leaderSkills!.eligibleLeaders; + const auditor = player(f.afterSetup, f.owner).leaders.find(isAuditorLeader)!; + assert.ok(auditor && eligible.every(l => l.id !== auditor.id)); + assert.ok(f.afterSetup.leaderSkills!.assignments.every(a => a.leader !== auditor.id)); + const revealed = reveal(f), quote = quoteNativeSkillsStrongholdBattle(revealed); + assert.equal(quote.auditor!.owner, f.owner); + assert.equal(quote.auditor!.survived, true); + const resolved = settle(revealed); + assert.equal(resolved.lastBattleContext!.winner, f.owner); + assert.equal(player(resolved, f.owner).forces[f.location], 4); + assert.equal(player(resolved, f.owner).tanks, 4, 'Unskilled Auditor has no trained-disc Suk rescue'); + assertBattleWallets(revealed, resolved); custody(resolved); +}); + +void test('Tabr/Tuek original winner keeps spice/cards and optional Tech before Face Dance; matching-disc death returns Suk once without a second bounty', () => { + for (const kind of ['tleilaxu-tabr', 'tleilaxu-tuek'] as const) { + for (const optionalTech of [false, true]) { + const f = fixture({ kind, optionalTech }); + const revealed = reveal(f), quote = quoteNativeSkillsStrongholdBattle(revealed); + assert.deepEqual(quote.strongholdIncome, [{ player: f.owner, amount: 2 }]); + const suk = settle(revealed, 'suk'); + assert.equal(suk.decision?.kind, 'sukRescue'); + const cards = settle(suk, 'cards'); + assert.equal(cards.decision?.kind, 'battleCards'); + assert.equal(cards.decision?.player, f.owner); + const usedDefense = f.plans[0].action.defense as string; + const cleaned = applyAction(cards, f.owner, { type: 'decision', discard: [usedDefense] }); + const face = settle(cleaned, 'faceDance'); + assert.equal(face.decision?.kind, 'faceDance'); + assert.ok(face.discard.some(c => c.id === usedDefense), 'Original winner card choice finishes before Face Dance'); + assert.equal(player(face, f.owner).forces[f.location], 5, 'Original Suk rescue completes first'); + assert.equal(player(face, f.owner).tanks, 1); + assert.equal(player(face, f.owner).spice, player(revealed, f.owner).spice - 4 + quote.bounty!.amount + 2); + if (optionalTech) assert.ok(ownedTech(face.techTokens, f.owner).includes('axlotl')); + const cardIds = player(face, f.owner).hand.map(c => c.id).sort(); + const reserves = player(face, f.owner).reserves; + const dancerReserves = player(face, f.tleilaxu!).reserves; + const danced = applyAction(face, f.tleilaxu!, { type: 'decision', reveal: true, + sources: { reserves: 1, [f.boardSource]: 2 }, sector: Number(f.location.split(':')[1]) }); + assert.equal(player(danced, f.owner).reserves, reserves + 5); + assert.equal(player(danced, f.owner).forces[f.location] ?? 0, 0); + assert.equal(player(danced, f.tleilaxu!).forces[f.location], 3); + assert.equal(player(danced, f.tleilaxu!).forces[f.boardSource], 1); + assert.equal(player(danced, f.tleilaxu!).reserves, dancerReserves - 1); + 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 === 'suk-graduate').length, 1); + assert.deepEqual(player(danced, f.owner).hand.map(c => c.id).sort(), cardIds); + for (const p of face.players) + assert.equal(player(danced, p.id).spice, p.spice + collectionIncome(danced, p.id), + `${p.faction}: Face Dance awards no additional leader bounty or confiscation`); + if (optionalTech) assert.ok(ownedTech(danced.techTokens, f.owner).includes('axlotl')); + assert.equal(nativeSkillsStrongholdControllers(danced)[f.territory], f.tleilaxu); + assert.equal(danced.strongholdCards!.owners[f.territory], f.owner, 'Board replacement does not immediately transfer retained Stronghold Card'); + const ended = finishNativeSkillsStrongholdTurn(danced); + assert.equal(ended.strongholdCards!.owners[f.territory], f.tleilaxu); + assert.equal(ended.leaderSkills!.deck.filter(s => s === 'suk-graduate').length, 1); + custody(danced); custody(ended); + } + } +}); + +void test('original mixed ordinary-force/No-Field boundary remains guarded even with trained Suk and a retained Stronghold Card', () => { + const f = fixture({ kind: 'richese-marker' }); + const game = structuredClone(f.beforeBattle), richese = player(game, f.owner); + // Explicit conserved alternative board position: one existing Polar Sink + // counter accompanies the marker, not a shipment or played-turn claim. + richese.forces[f.boardSource]--; + richese.forces[f.location] = 1; + const before = structuredClone(game), actor = game.active!; + assert.throws(() => applyAction(game, actor, { type: 'chooseBattle', territory: f.territory, + target: actor === f.owner ? f.opponent : f.owner }), /Mixed ordinary-force and No-Field/); + assert.deepEqual(game, before); custody(game); +}); + +void test('reserve-capped marker-only Richese materializes three after both plans, earns retained Arrakeen subsidy and rescues only physical casualties', () => { + const f = fixture({ kind: 'richese-marker', optionalTech: true }); + assert.equal(player(f.shipment!.after, f.owner).spice, player(f.shipment!.before, f.owner).spice - 1); + const own = viewGame(f.game, f.owner).battle!; + assert.equal(own.ownForces!.normal, 3); + const before = structuredClone(f.game); + assert.throws(() => applyAction(f.game, f.owner, { ...f.plans[0].action, dial: 4 })); + assert.deepEqual(f.game, before); + const sealed = applyAction(f.game, f.owner, f.plans[0].action); + assert.ok(player(sealed, f.owner).noField!.deployed); + assert.equal(player(sealed, f.owner).forces[f.location] ?? 0, 0); + const revealed = reveal(f), quote = quoteNativeSkillsStrongholdBattle(revealed); + assert.equal(player(revealed, f.owner).forces[f.location], 3); + assert.equal(quote.payments.find(p => p.player === f.owner)!.ownPayment, 1); + assert.equal(quote.payments.find(p => p.player === f.owner)!.bankSupport, 2); + const resolved = settle(revealed); + assert.equal(player(resolved, f.owner).forces[f.location], 1); + assert.equal(player(resolved, f.owner).reserves, 2); + assert.equal(player(resolved, f.owner).tanks, 0); + assert.equal(player(resolved, f.opponent).tanks, 6); + assertBattleWallets(revealed, resolved); custody(resolved); +}); + +void test('native cache-acquired Stone uses retained Habbanya in both modes with all skills hidden and untrained battle discs', () => { + for (const mode of ['kill', 'ignore'] as const) { + const f = fixture({ kind: 'richese-stone' }); + assert.ok(f.acquisition!.before.richeseCache!.some(c => c.id === 'richese-stone-burner')); + assert.ok(player(f.acquisition!.after, f.owner).hand.some(c => c.id === 'richese-stone-burner')); + assert.equal(f.acquisition!.after.richeseCache!.some(c => c.id === 'richese-stone-burner'), false); + for (const p of f.acquisition!.before.players) assert.equal(player(f.acquisition!.after, p.id).spice, p.spice); + const offered = reveal(f); + assert.equal(offered.decision?.kind, 'stoneBurner'); + const chosen = applyAction(offered, f.owner, { type: 'decision', event: offered.battle!.event, mode }); + const quote = quoteNativeSkillsStrongholdBattle(chosen), resolved = settle(chosen); + assert.equal(quote.winner, f.owner); + assert.deepEqual(quote.stone!.attacker, [6]); + assert.deepEqual(quote.stone!.defender, [6]); + assert.equal(player(resolved, f.owner).forces[f.location], 6); + assert.equal(player(resolved, f.owner).tanks, 2); + for (const plan of f.plans) assert.equal(player(resolved, plan.actor).leaders.find(l => l.id === plan.action.leader)!.dead, mode === 'kill'); + assert.ok(player(resolved, f.owner).hand.some(c => c.id === 'richese-stone-burner')); + assertBattleWallets(chosen, resolved); custody(resolved); + } +}); + +void test('four existing policies finish changed HMS/Suk and capped-marker windows with physical and economic outcomes', () => { + const ix = fixture({ kind: 'ix-copy-suk' }); + const marker = fixture({ kind: 'richese-marker' }); + const stone = fixture({ kind: 'richese-stone' }); + const stoneChoice = reveal(stone); + const ixRescue = settle(reveal(ix), 'suk'); + const markerRescue = settle(reveal(marker), 'suk'); + for (const difficulty of DIFFICULTIES) { + const copyView = viewGame(ix.game, ix.owner); copyView.players.find(p => p.id === ix.owner)!.bot = difficulty; + const copy = botActions(copyView)[0]; + const declared = applyAction(ix.game, ix.owner, copy); + assert.ok(['arrakeen', 'sietch_tabr'].includes(declared.battle!.strongholdCopy!)); + const copiedBattle = reveal({ ...ix, copyAction: copy }); + const copiedQuote = quoteNativeSkillsStrongholdBattle(copiedBattle); + assert.equal(copiedQuote.payments.find(p => p.player === ix.owner)!.ownPayment, + declared.battle!.strongholdCopy === 'arrakeen' ? 0 : 1); + const copiedResult = settle(copiedBattle); + assert.equal(player(copiedResult, ix.owner).forces[ix.location], 3); + assert.equal(player(copiedResult, ix.owner).tanks, 3); + assertBattleWallets(copiedBattle, copiedResult); + let markerPlans = structuredClone(marker.game); + for (const actor of [marker.owner, marker.opponent]) { + const view = viewGame(markerPlans, actor); view.players.find(p => p.id === actor)!.bot = difficulty; + const plan = botActions(view).find(a => a.type === 'battlePlan'); assert.ok(plan); + markerPlans = applyAction(markerPlans, actor, plan); + for (let n = 0; !markerPlans.battle?.revealed && (markerPlans.response || markerPlans.decision) && n < 100; n++) { + const next = nextNativeSkillsStrongholdStep(markerPlans); assert.ok(next); + markerPlans = applyAction(markerPlans, next.actor, next.action); + } + } + assert.equal(player(markerPlans, marker.owner).forces[marker.location], 3); + const markerResult = settle(markerPlans); + assertBattleWallets(markerPlans, markerResult); custody(markerResult); + const stoneView = viewGame(stoneChoice, stone.owner); stoneView.players.find(p => p.id === stone.owner)!.bot = difficulty; + const mode = botActions(stoneView)[0]; + const chosenStone = applyAction(stoneChoice, stone.owner, mode), stoneResult = settle(chosenStone); + assert.equal(stoneResult.lastBattleContext!.winner, stone.owner); + assert.equal(player(stoneResult, stone.owner).forces[stone.location], 6); + for (const plan of stone.plans) + assert.equal(player(stoneResult, plan.actor).leaders.find(l => l.id === plan.action.leader)!.dead, mode.mode === 'kill'); + assertBattleWallets(chosenStone, stoneResult); custody(stoneResult); + for (const [f, pending] of [[ix, ixRescue], [marker, markerRescue]] as const) { + const view = viewGame(pending, f.owner); view.players.find(p => p.id === f.owner)!.bot = difficulty; + const action = botActions(view)[0]; + const rescued = settle(applyAction(pending, f.owner, action)); + assert.equal(rescued.lastBattleContext!.winner, f.owner); + assert.equal(player(rescued, f.owner).tanks, f.kind === 'richese-marker' ? 0 : 3, + `${difficulty}/${f.kind}: three real casualties saved, no unpaid cyborg manufactured`); + assert.equal(player(rescued, f.opponent).tanks, f.kind === 'richese-marker' ? 6 : 8); + if (f.kind === 'richese-marker') { + assert.equal(player(rescued, f.owner).forces[f.location], 1); + assert.equal(player(rescued, f.owner).reserves, 2); + } else assert.equal(player(rescued, f.owner).forces[f.location], 3); + custody(rescued); + } + } +}); diff --git a/tests/prototype-room.test.ts b/tests/prototype-room.test.ts index 1885e1e1..44939064 100644 --- a/tests/prototype-room.test.ts +++ b/tests/prototype-room.test.ts @@ -880,7 +880,7 @@ void test('Banker income rejects incompatible profiles and public activation wit game => { game.players.splice(1); }, game => { game.players.push(...Array.from({ length: 4 }, (_, index) => newPlayer(`extra${index}`, `Extra ${index}`, 'guild'))); }, ]; - const modules = ['homeworlds', 'strongholdCards'].map(type => { + const modules = ['homeworlds'].map(type => { const game = applyAction(native, native.host, { type, enabled: true }); for (const player of game.players) player.ready = true; return game; diff --git a/tools/faction-games.ts b/tools/faction-games.ts index de551a16..58203289 100644 --- a/tools/faction-games.ts +++ b/tools/faction-games.ts @@ -30,14 +30,15 @@ import { type Game, } from '../game/engine'; import { sampleInventory, verifySampleCustody } from './sample-custody'; -import { classicTechLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile } from '../game/leader-skill-profile'; +import { classicTechLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, strongholdLeaderSkillsProfile } from '../game/leader-skill-profile'; import { createTechTokens } from '../game/tech-tokens'; +import { createStrongholdCards } from '../game/stronghold-cards'; import { privateOutputDirectory, sourceSnapshot } from './verification'; const DEFAULT_SEED = 20_260_926; const DEFAULT_MAX_ACTIONS = 3_500; const DIFFICULTIES = ['Easy', 'Medium', 'Hard', 'Brutal'] as const; -type Profile = 'base' | 'choam' | 'ecaz' | 'ecaz-treachery' | 'ecaz-occupy' | 'moritani-assassinate' | 'stronghold-factions' | 'combined' | 'combined-nexus' | 'combined-homeworld-nexus' | 'homeworld' | 'nexus' | 'homeworld-nexus' | 'choam-roster' | 'ecaz-roster' | 'ix-roster' | 'choam-nexus' | 'ecaz-nexus' | 'ix-nexus' | 'moritani-skills' | 'tleilaxu-skills' | 'ix-skills' | 'choam-skills' | 'richese-skills' | 'ecaz-skills' | 'skills-tech'; +type Profile = 'base' | 'choam' | 'ecaz' | 'ecaz-treachery' | 'ecaz-occupy' | 'moritani-assassinate' | 'stronghold-factions' | 'combined' | 'combined-nexus' | 'combined-homeworld-nexus' | 'homeworld' | 'nexus' | 'homeworld-nexus' | 'choam-roster' | 'ecaz-roster' | 'ix-roster' | 'choam-nexus' | 'ecaz-nexus' | 'ix-nexus' | 'moritani-skills' | 'tleilaxu-skills' | 'ix-skills' | 'choam-skills' | 'richese-skills' | 'ecaz-skills' | 'skills-tech' | 'skills-stronghold' | 'skills-stronghold-tech'; type Rules = 'basic' | 'advanced'; type Scenario = { @@ -361,7 +362,17 @@ const SKILLS_TECH_SCENARIOS: readonly Scenario[] = [ .filter(scenario => scenario.roster.length >= 3) .map((scenario, index): Scenario => ({ ...scenario, ordinal: 316 + index, profile: 'skills-tech' })), ]; -const skillProfile = (profile: string) => profile === 'moritani-skills' || profile === 'tleilaxu-skills' || profile === 'ix-skills' || profile === 'choam-skills' || profile === 'richese-skills' || profile === 'ecaz-skills' || profile === 'skills-tech'; +const SKILLS_STRONGHOLD_SCENARIOS: readonly Scenario[] = + (['skills-stronghold', 'skills-stronghold-tech'] as const).flatMap((profile, band) => [ + ...[2, 3, 4, 5, 6].map(players => ({ + rules: 'advanced' as const, expansions: [], roster: BASE_ROSTER.slice(0, players), + })), + ...[...IX_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS, ...RICHESE_SKILLS_SCENARIOS] + .filter(scenario => scenario.rules === 'advanced'), + ].filter(scenario => profile !== 'skills-stronghold-tech' || scenario.roster.length >= 3) + .map((scenario, index): Scenario => ({ ...scenario, ordinal: 376 + band * 100 + index, profile }))); +const strongholdSkillProfile = (profile: string) => profile === 'skills-stronghold' || profile === 'skills-stronghold-tech'; +const skillProfile = (profile: string) => profile === 'moritani-skills' || profile === 'tleilaxu-skills' || profile === 'ix-skills' || profile === 'choam-skills' || profile === 'richese-skills' || profile === 'ecaz-skills' || profile === 'skills-tech' || strongholdSkillProfile(profile); type TraceEntry = { attempt: number; @@ -399,7 +410,7 @@ type Result = { function usage() { return ( 'Usage: node --import tsx tools/faction-games.ts --out NEW_PRIVATE_DIR ' + - '[--seed UINT32] [--profile all|base|choam|ecaz|ecaz-treachery|ecaz-occupy|moritani-assassinate|stronghold-factions|combined|combined-nexus|combined-homeworld-nexus|homeworld|nexus|homeworld-nexus|choam-roster|ecaz-roster|ix-roster|choam-nexus|ecaz-nexus|ix-nexus|moritani-skills|tleilaxu-skills|ix-skills|choam-skills|richese-skills|ecaz-skills|skills-tech] ' + + '[--seed UINT32] [--profile all|base|choam|ecaz|ecaz-treachery|ecaz-occupy|moritani-assassinate|stronghold-factions|combined|combined-nexus|combined-homeworld-nexus|homeworld|nexus|homeworld-nexus|choam-roster|ecaz-roster|ix-roster|choam-nexus|ecaz-nexus|ix-nexus|moritani-skills|tleilaxu-skills|ix-skills|choam-skills|richese-skills|ecaz-skills|skills-tech|skills-stronghold|skills-stronghold-tech] ' + '[--rules both|basic|advanced] [--players all|2|3|4|5|6] [--max-actions POSITIVE] ' + '[--resume FAILED_GAME.json]\n' + 'Runs genuine setup and gameplay offline. Default/all keeps the six expansion samples; combined-nexus and combined-homeworld-nexus add five/six-seat Basic/Advanced all-expansion samples. Base, Homeworld, Nexus, Homeworld-Nexus, expansion roster, paired expansion Nexus, Ecaz card variant, Advanced Moritani assassination and skill profiles select their documented rosters. Stronghold-factions is Advanced only: Ixians + CHOAM, native Ixians + Tleilaxu, or native CHOAM + Richese with classic opponents, two through six seats; mixed four-native rosters also run at four through six seats. Selected Ix/CHOAM decks determine the canonical 47/35 Treachery Cards; Richese has a separate ten-card cache and all samples have six separate Stronghold Cards without other modules. Scenario names identify their native roster. --players requires a supported profile. Output must be a new private directory outside the checkout.' + @@ -408,6 +419,7 @@ function usage() { + '\nEcaz-skills uses native five-disc Ecaz with classic opponents, the ecaz deck and all fourteen skills in Basic/Advanced; Advanced Harkonnen is excluded by the existing shared-Duke capture boundary. Ambassador and ordinary skill paths retain native custody. Shared-Duke assignment and combined Occupy skills remain separate boundaries; no public starts or save retrofit.' + '\nStandalone E3 Stronghold samples additionally use native Ecaz OR Moritani with classic opponents and the exact ecaz deck, six Stronghold Cards only. Ecaz holder-only coalition effects and original Moritani assassination remain; Moritani excludes Harkonnen. E3 pairs, E1/E2 mixtures and other overlays remain guarded.' + '\nSkills-tech uses three-through-six unique seats in Basic/Advanced: classic/base33 or the already-supported native Ixian/Tleilaxu/CHOAM/Richese skill rosters with original selected ix/choam decks and separate Richese cache. All fourteen Skills and three original Tech Tokens retain original setup/native owners, phase-end income, aftermath and mandatory original-winner token transfer before Face Dance. Other modules/E3, public starts and played-game conversion stay separate.' + + '\nSkills-stronghold uses fresh Advanced classic or supported E1/E2 native two-through-six-seat skill rosters with all14 skills and six original Stronghold Cards. Skills-stronghold-tech adds three original Tech Tokens and requires three-through-six seats. Original native decks/cache, real end-Mentat Stronghold ownership and printed/first-Storm Tech assignment remain; E3, other modules, public starts and played-game conversion stay separate.' ); } @@ -438,7 +450,7 @@ function positive(value: string | undefined) { } function scenarioName(scenario: Scenario) { - if (scenario.profile === 'skills-tech' && scenario.expansions.length) { + if ((scenario.profile === 'skills-tech' || strongholdSkillProfile(scenario.profile)) && scenario.expansions.length) { const native = scenario.roster.filter(faction => ['ixians', 'tleilaxu', 'choam', 'richese'].includes(faction)); const extraIx = scenario.expansions.includes('ix') && !native.some(faction => faction === 'ixians' || faction === 'tleilaxu'); return `${scenario.profile}-${native.join('-')}${extraIx ? '-ix-deck' : ''}-${scenario.roster.length}-${scenario.rules}`; @@ -470,7 +482,7 @@ function scenarioName(scenario: Scenario) { function parseProfile(value: string | undefined) { const profile = value ?? 'all'; - if (!['all', 'base', 'choam', 'ecaz', 'ecaz-treachery', 'ecaz-occupy', 'moritani-assassinate', 'stronghold-factions', 'combined', 'combined-nexus', 'combined-homeworld-nexus', 'homeworld', 'nexus', 'homeworld-nexus', 'choam-roster', 'ecaz-roster', 'ix-roster', 'choam-nexus', 'ecaz-nexus', 'ix-nexus', 'moritani-skills', 'tleilaxu-skills', 'ix-skills', 'choam-skills', 'richese-skills', 'ecaz-skills', 'skills-tech'].includes(profile)) + if (!['all', 'base', 'choam', 'ecaz', 'ecaz-treachery', 'ecaz-occupy', 'moritani-assassinate', 'stronghold-factions', 'combined', 'combined-nexus', 'combined-homeworld-nexus', 'homeworld', 'nexus', 'homeworld-nexus', 'choam-roster', 'ecaz-roster', 'ix-roster', 'choam-nexus', 'ecaz-nexus', 'ix-nexus', 'moritani-skills', 'tleilaxu-skills', 'ix-skills', 'choam-skills', 'richese-skills', 'ecaz-skills', 'skills-tech', 'skills-stronghold', 'skills-stronghold-tech'].includes(profile)) throw new Error('--profile must name a documented base, module, expansion or native skill sample profile.'); return profile as Profile | 'all'; } @@ -516,7 +528,8 @@ function freshGame(scenario: Scenario) { player.ready = true; } if (scenario.ecazTreachery) game.ecazTreachery = true; - if (scenario.profile === 'skills-tech') game.techTokens = createTechTokens(); + if (scenario.profile === 'skills-tech' || scenario.profile === 'skills-stronghold-tech') game.techTokens = createTechTokens(); + if (strongholdSkillProfile(scenario.profile)) game.strongholdCards = createStrongholdCards(); if (scenario.profile === 'homeworld' || scenario.profile === 'homeworld-nexus' || scenario.profile === 'combined-homeworld-nexus') game.homeworlds = { custody: null }; if (scenario.profile === 'nexus' || 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') @@ -575,24 +588,24 @@ function resumedGame(path: string) { throw new Error( '--resume is not an incomplete faction-games snapshot with saved AI profiles.', ); - const techSkills = (classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game)) && !game.spiceBankerIncomePreview; + const composedSkills = (classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game) || strongholdLeaderSkillsProfile(game)) && !game.spiceBankerIncomePreview; if ( - (game.leaderSkills && !nativeExpansionLeaderSkillsProfile(game) && !techSkills) || + (game.leaderSkills && !nativeExpansionLeaderSkillsProfile(game) && !composedSkills) || game.discoveryEnabled || game.discoveries || game.discoveryStash || game.greatMaker || - (game.techTokens && !techSkills) || + (game.techTokens && !composedSkills) || 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].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].find( (candidate) => (candidate.profile === 'homeworld' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.homeworlds && (candidate.profile === 'nexus' || 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 && skillProfile(candidate.profile) === !!game.leaderSkills && - (candidate.profile === 'skills-tech') === !!game.techTokens && - (candidate.profile === 'stronghold-factions') === !!game.strongholdCards && + (candidate.profile === 'skills-tech' || candidate.profile === 'skills-stronghold-tech') === !!game.techTokens && + (candidate.profile === 'stronghold-factions' || strongholdSkillProfile(candidate.profile)) === !!game.strongholdCards && (candidate.profile === 'ecaz-treachery' || candidate.ecazTreachery === true) === !!game.ecazTreachery && (candidate.profile === 'moritani-assassinate' || (candidate.profile === 'moritani-skills' && candidate.rules === 'advanced') || (candidate.profile === 'stronghold-factions' && candidate.roster.includes('moritani'))) === !!game.moritaniAssassinatePreview && @@ -810,9 +823,9 @@ async function main() { throw new Error(`--profile ${profile} supports only five or six players.`); if (profile === 'moritani-assassinate' && rules === 'basic') throw new Error('Moritani assassination samples require Advanced rules.'); - if (profile === 'stronghold-factions' && rules === 'basic') - throw new Error('Stronghold factions samples require Advanced rules.'); - if (profile === 'skills-tech' && players === 2) + if ((profile === 'stronghold-factions' || strongholdSkillProfile(profile)) && rules === 'basic') + throw new Error('Stronghold samples require Advanced rules.'); + if ((profile === 'skills-tech' || profile === 'skills-stronghold-tech') && players === 2) throw new Error('Leader Skills with Tech Tokens samples require three through six players.'); const resume = values.resume ? resumedGame(values.resume) : null; const requestedSamples = profile === 'homeworld' || profile === 'nexus' || profile === 'homeworld-nexus' @@ -823,6 +836,7 @@ async function main() { : profile === 'moritani-assassinate' ? MORITANI_ASSASSINATE_SCENARIOS : profile === 'stronghold-factions' ? STRONGHOLD_FACTIONS_SCENARIOS : profile === 'skills-tech' ? SKILLS_TECH_SCENARIOS + : strongholdSkillProfile(profile) ? SKILLS_STRONGHOLD_SCENARIOS : profile === 'choam-nexus' || profile === 'ecaz-nexus' || profile === 'ix-nexus' ? PAIRED_NEXUS_SCENARIOS : profile === 'combined-nexus' ? COMBINED_NEXUS_SCENARIOS : profile === 'combined-homeworld-nexus' ? COMBINED_HOMEWORLD_NEXUS_SCENARIOS diff --git a/tools/start-prototype.ts b/tools/start-prototype.ts index c9290a82..2023a926 100644 --- a/tools/start-prototype.ts +++ b/tools/start-prototype.ts @@ -27,6 +27,7 @@ async function main() { '\nLeader-skills retains existing classic and Basic native entries and adds native Basic Richese/CHOAM. Advanced Ixians, Tleilaxu, CHOAM and/or Richese plus classic rosters use distinct required family decks and all fourteen skills, with original cache, Face Dancers, Auditor exclusion and typed aftermath. Foreign revival grants no new skill. Separate non-Harkonnen Advanced Moritani uses the ecaz deck and original skill-first assassination preview. Native Ecaz supports its five ordinary discs, original Ambassador/Duke custody and ecaz deck with classic opponents in Basic/Advanced; Advanced Harkonnen retains its existing Duke capture exclusion. Explicit --mentat-question stays private; banker-income additionally supports native Richese and Ecaz, never ordinary seller/allowed Emperor income or a bank grant as a payer cost. Basic auction recipients remain unchanged. Public activation, exhausted-cache counts, native Ixian Technology on Richese lots, own mixed No-Field, captured replacement, shared-Duke assignment, combined Occupy skills and other pending module/rulings remain guarded.', '\nStronghold-factions additionally admits standalone Advanced Ecaz OR Moritani with classic opponents and the exact ecaz deck, six Stronghold Cards only. Original setup and actual end-Mentat claims remain. Ecaz holds shared-control cards and only the actual selected holder-plan benefits. Native Moritani retains original assassination with printed bounty and one private Mentat replacement; Harkonnen is excluded there. An E3 pair, E1/E2/E3 mixtures and other modules remain separate.' + '\nLeader-skills and banker-income preserve canonical unused Tech Tokens selected in fresh three-through-six-seat classic or existing supported E1/E2 native Basic/Advanced lobbies. Original selected decks, Richese cache, all fourteen skills, printed native Tech owners, phase-end income and mandatory original-winner reward before Face Dance remain. Banker income and --mentat-question stay separate existing opt-ins. E3/other modules and played-game conversion remain excluded.', + '\nLeader-skills and banker-income also preserve canonical unused Stronghold Cards in fresh Advanced classic or supported E1/E2 native two-through-six-seat lobbies. Optional Tech Tokens requires three-through-six seats. All14 original skills, decks/cache and actual end-Mentat Stronghold assignment remain. Basic Strongholds, E3/other overlays, public activation and save conversion stay excluded.', ); return; }