diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 76f68fd4..f0926fa4 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,28 @@ open mode gates. ## Current checkpoint and work +**Standalone E3 Skills / Tech / Strongholds — bounded Development Verified:** +fresh original Ecaz OR Moritani ecaz33/all14 profiles preserve Tech3..6 +Basic/Advanced and Strongholds2..6 Advanced, optionally both. Original +Ecaz ordinary-five/Duke assignment and Advanced-Hark exclusions, skill-first +Moritani assassination/normal-call forfeiture and unrelated guards remain. +New15/affected152, types/lint/build and nine actual programs pass. Eight genuine +E3 games complete1,531 actions/no rejection/38 JSON continuations; broader47 +games complete13,936 actions/no rejection/358 continuations on unchanged source. +Original CLI **X4YSWBLR/BJH3UVQF v8** keeps genuine setup and unused modules. +Human390px **334W75B6 v13/Collection** uses normal Warmaster1/Duke6/Carthag +Shield for a real7–6 poison-surviving win, living release and sole Axlotl. +**AL6WCG8B v13/Mentat** retains normal Guild Suk rescue, actual losing +Tuek income/support, printed Master3 bounty/skill return, winner Tech and one +private Caid replacement. Collection stays separate; refresh/no overflow pass. +Controlled positions are labeled. E3 pairs/mixtures, allied Occupy skills, +other overlays, public starts, save conversion and new rulings remain excluded. +See [scope and source-bound evidence](LEADER_SKILLS_RUNTIME.md#standalone-e3-module-composition--4-october-2026). +Full rules, assurance, AI, public and deployed acceptance remain open. +Final guide10/10, types/lint/build and rebuilt390px E3 reference pass +(351px topic, no overflow). Final isolated reload retains original human +v13/Collection andv13/Mentat; owned drivers/tabs are removed, rooms/store remain. + **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, diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 80e94b9b..4ff38b55 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,34 @@ # Dune implementation status +## 4 October 2026 — standalone E3 skills with Tech and Advanced Strongholds + +Two bounded owners supplied Ecaz ordinary-disc/Duke and Moritani +rescue/assassination/replacement programs. Parent integrated existing skill +admission, original assassination context, appended sample/resume rows and +guidance. Standalone Ecaz OR Moritani ecaz33/all14 admits Tech Basic/Advanced +3..6 and Stronghold Advanced2..6, optionally both. Original setup/first-Storm/ +end-Mentat and exclusions remain; no public start, save conversion or ruling. + +New15/affected152, types/lint/build and nine actual programs pass. +Original probes:22 valid entries/ten retained rule-bound rejections. +Eight E3 games:1,531 actions/no rejection/38 JSON; expanded47-game batch: +13,936 actions/no rejection/358 JSON. Captured unchanged tree +`d9c5e4d1a611f8ab7cf18e4c816161604d18f8aa8c94b25962fc38595fcc71a3`. +Natural Moritani samples do not claim assassination; controlled/human paths +prove it. Legal later Basic dials, prior BG prediction, unoccupied Moritani +setup, real Guild allowance/accrual and separate Tuek Collection correct fixture +assumptions. Startup wallet/location pins were removed, not repinned. + +Original CLI X4YSWBLR/BJH3UVQFv8 and human390px334W75B6v13/Collection, +AL6WCG8Bv13/Mentat retain original IDs/pieces. Real normal Warmaster1/Duke6/ +Carthag Shield wins7–6 against poison and releases the living Duke. Normal +Guild Suk precedes unused Master3 death/skill return/printed bounty, losing +Tuek income and one private Caid replacement. Cards/Tech/physical casualties +and separately quoted Collection remain; refresh/no overflow pass. +Detailed mechanics/evidence: +[existing runtime](LEADER_SKILLS_RUNTIME.md#standalone-e3-module-composition--4-october-2026). +No full rules, assurance, strategic-AI, public or deployed claim. + ## 4 October 2026 — Advanced Skills and Strongholds, with optional Tech Two bounded owners supplied classic subsidy/defense/income/rescue and native diff --git a/docs/LEADER_SKILLS_RUNTIME.md b/docs/LEADER_SKILLS_RUNTIME.md index 1ad19a80..91216411 100644 --- a/docs/LEADER_SKILLS_RUNTIME.md +++ b/docs/LEADER_SKILLS_RUNTIME.md @@ -29,6 +29,71 @@ Advanced native/combined-module skills remain guarded. See [native preview scope](MENTAT_QUESTION.md#native-skill-integration--2-october-2026). +## Standalone E3 module composition — 4 October 2026 + +Fresh original Ecaz OR Moritani skill profiles preserve unused Tech Tokens in +Basic/Advanced3..6 and Stronghold Cards in Advanced2..6, optionally both. +Exact ecaz33/all14, native setup/wallets and actual first-Storm/end-Mentat +ownership remain. Ecaz trains its five ordinary discs, not Duke; Advanced +Harkonnen exclusion remains. Advanced Moritani keeps non-Harkonnen skill-first +assassination and normal-call forfeiture. Basic has no Advanced assassination. +E3 pairs, mixed families, allied Occupy skills, other overlays, public starts +and save conversion remain excluded. + +Ecaz ordinary Suk preserves support and original card/Tech order. Actual +paid Ambassador placement, entrant shipment and acquisition supply temporary +Duke6. Living face-up Warmaster adds normal1 with Worthless, never trained3. +Carthag adds poison protection to physical Shield. Living release or real +printed6 death preserves native training; original paid5 revival returns the +same living set-aside Duke under the earlier ruling. Separate Banker counts +actual opposing paid4, not subsidy or paid1, and waits for actual Mentat. + +Normal Guild Suk saves one before original after-loss assassination. The used +battle disc stays ineligible; unused Master Bewt3 can die, returns Suk once and +pays printed3, without replaying casualties or winner income. Original winner +cards/Tech and retained Strongholds remain. Actual Mentat gives one private +replacement. A single death does not bypass ordinary all-five-disc revival. + +New15 meaningful cases, affected152/152, types/lint/build and nine actual +programs pass. Original probes cover22 valid two/three/six-seat entries and +ten retained Basic-Stronghold/Advanced-Hark rejections. +Eight genuine E3 games complete1,531 actions/no rejection/38 JSON continuations; +broader47 games complete13,936 actions/no rejection/358 continuations. +Captured unchanged tree: +`d9c5e4d1a611f8ab7cf18e4c816161604d18f8aa8c94b25962fc38595fcc71a3`. +Natural Moritani samples do not claim assassination; controlled programs prove +it separately. Existing sample names/ordinals remain; new E3 rows append and +resume matches original assassination markers. + +Fixture corrections preserve original chronology: later Basic dials use1..3, +BG prediction precedes skill offers and Moritani's start includes no other +fighters/advisors. Wallet/location pins were removed, not repinned. Actual +Guild allowance finishes before shipment/accrual; the spice-free desert +shipment does not occupy the conserved diverted deposit. Advanced Tuek +Collection1 is separately quoted, not confused with assassination bounty. + +Original CLI **X4YSWBLR/BJH3UVQF v8/setup** retains six unowned cards/three +unowned tokens and genuine Ecaz/Moritani skill setup. +Human390px **334W75B6 v13/Collection** uses acquired Duke6/La La La/Shield at +held Carthag, normal Warmaster1, against dial1/support1/Captain Aramsham5/ +Chaumurky. Real7–6 poison-surviving victory releases Duke living, preserves +native Warmaster and both cards. Ecaz8 survive, Emperor8 Tanks; sole opposing +Axlotl transfers, Atreides retains Production. Wallet12 plus separate Carthag2 +becomes14. +Human390px **AL6WCG8B v13/Mentat** follows normal Guild Suk rescue1, then +selects unused Master Bewt3. Moritani retains losing Tuek Worthless4/support1; +actual wallet14→17 printed bounty. Guild3/Tanks2/reserves15, Moritani6 Tanks/ +one shipped Wind Pass counter remain without repeated losses. Disc death +returns Suk once; Guild discards its original card and takes only Heighliners, +retaining Production while Emperor keeps Axlotl. Separate Guild Tuek income1 +remains. Real readiness draws one private Caid; retired Master stays public. +Refresh retains both versions/outcomes/no overflow. +Full combinations, assurance, strategic AI, public and deployed gates remain. +Final reference10/10, types/lint/build and rebuilt390px E3 guide pass: +351px topic,390px page, no overflow. The final isolated reload retains both +original human v13/Collection andv13/Mentat. Owned temporary drivers/tabs +are removed; rooms/store, private metadata and original captures remain. + ## Advanced Stronghold composition — 4 October 2026 Fresh Advanced `leader-skills` and separate `banker-income` entries preserve diff --git a/docs/MORITANI_LEADER_SKILLS.md b/docs/MORITANI_LEADER_SKILLS.md index 32a9fe8e..d999ccca 100644 --- a/docs/MORITANI_LEADER_SKILLS.md +++ b/docs/MORITANI_LEADER_SKILLS.md @@ -5,6 +5,21 @@ two through six total seats with base-faction opponents now use the full fourteen-card Leader Skills deck. Public starts and publication remain gated; this integrates existing effects, not complete Leader Skills compliance. +## Standalone module composition — 4 October 2026 + +The same original standalone profile now retains canonical unused Tech Tokens +in Basic/Advanced3..6 and Stronghold Cards in Advanced2..6, optionally both. +Exact ecaz33/all14 and original native five-disc/setup remain; Advanced still +excludes Harkonnen and preserves original assassination/normal-call forfeiture. +Normal unused Guild Suk saves one before eligible Master Bewt3 assassination; +the exact skill returns once, printed3 pays and original winner rewards remain. +Losing Moritani retains actually played Tuek Worthless income/support. Actual +Mentat replaces the revealed physical card once; Collection remains separate. +No E3 pair, mixture, allied Occupy skill, public start or played-save conversion. +Human390px **AL6WCG8B v13/Mentat** verifies printed14→17 bounty, one private +Caid replacement and original physical/Tech outcomes. Read +[the combined contract and evidence](LEADER_SKILLS_RUNTIME.md#standalone-e3-module-composition--4-october-2026). + ## Advanced assassination composition — 4 October 2026 Fresh local Advanced `leader-skills` tables now compose all fourteen physical diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 89ec863f..08ccd990 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -200,6 +200,7 @@ See [the cutover and Advanced sequence](MORITANI_ASSASSINATE_LEADERS.md#authorit | 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) | +| Standalone E3 Skills / Tech / Stronghold Cards | Original Ecaz OR Moritani ecaz33/all14 rosters preserve Tech Basic/Advanced3..6 and Stronghold Advanced2..6, optionally both. Native Ecaz five-disc/temporary Duke/Advanced-Hark exclusion and Moritani skill-first assassination/normal-call forfeiture remain. Real claimed effects, original support/rescue, printed Duke6 or unused-disc bounty and one Mentat replacement compose native rewards without new ownership or income rules. E3 pairs, mixed families, allied Occupy skills, other overlays, public starts and save conversion remain excluded. | [Source contracts and bounded evidence](LEADER_SKILLS_RUNTIME.md#standalone-e3-module-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 05ac96bb..6bd3a582 100644 --- a/docs/SPICE_BANKER_RUNTIME.md +++ b/docs/SPICE_BANKER_RUNTIME.md @@ -6,6 +6,13 @@ 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 standalone E3 modules:** original Ecaz/Moritani skills preserve +Tech Basic/Advanced3..6 and Strongholds Advanced2..6, optionally both. +Banker stays a separate opt-in: actual opposing paid4 qualifies, not bank2 +subsidy or paid1. Original Mentat collects once. Duke assignment and Moritani +assassination/normal-call limits remain; see +[combined source and cases](LEADER_SKILLS_RUNTIME.md#standalone-e3-module-composition--4-october-2026). + **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 diff --git a/docs/STRONGHOLD_CARDS.md b/docs/STRONGHOLD_CARDS.md index 75e36859..b3e396cb 100644 --- a/docs/STRONGHOLD_CARDS.md +++ b/docs/STRONGHOLD_CARDS.md @@ -2,6 +2,23 @@ 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. +## Standalone E3 skills and optional Tech — 4 October 2026 + +Fresh original native Ecaz OR Moritani ecaz33/all14 skill profiles additionally +retain unused Stronghold Cards in Advanced2..6, with original Tech3..6. +Actual end-Mentat/local held-card effects and native setup/wallets remain. +Ecaz five-disc/temporary Duke and Advanced-Hark exclusion, plus original +Moritani skill-first assassination/normal-call forfeiture/replacement remain. +E3 pairs, mixtures, allied Occupy skills and other overlays stay excluded. +Human390px **334W75B6 v13/Collection** combines Carthag Shield/normal +Warmaster1/acquired Duke6: real7–6 poison survival, living release, native +training/cards and original Tech. **AL6WCG8B v13/Mentat** retains Guild Suk +before unused-disc death, losing Tuek4/support1, printed3 and one replacement. +New15/affected152, types/lint/build, nine programs and eight E3 natural games +pass (1,531 actions/no rejection,38 JSON continuations). Read +[the existing combined contract](LEADER_SKILLS_RUNTIME.md#standalone-e3-module-composition--4-october-2026). +Full-module/public/deployed acceptance remains open. + ## Advanced Leader Skills composition — 4 October 2026 Fresh local `leader-skills` and separate `banker-income` preserve unused six diff --git a/game/engine.ts b/game/engine.ts index ba1a6d75..51ef11e0 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -7531,8 +7531,10 @@ function moritaniAssassinateModeSupported(g: Game) { g.players.some(p => p.faction === 'moritani') && g.players.every(p => ['moritani','atreides','beneGesserit','guild','emperor','fremen'].includes(p.faction)) && !g.nexusCards && (!g.leaderSkills || advancedMoritaniLeaderSkillsProfile(g)) && - (!g.strongholdCards || e3StrongholdFactionProfile(g)) && !g.homeworlds && - !g.discoveryEnabled && !g.discoveries && !g.techTokens; + (!g.strongholdCards || e3StrongholdFactionProfile(g) || + (!!g.leaderSkills && strongholdLeaderSkillsProfile(g))) && !g.homeworlds && + !g.discoveryEnabled && !g.discoveries && + (!g.techTokens || (!!g.leaderSkills && nativeTechLeaderSkillsProfile(g))); } function moritaniAssassinateContext(g: Game, receipt: MoritaniAssassinateReceipt) { const p = getPlayer(g,receipt.owner), opponent = getPlayer(g,receipt.opponent); diff --git a/game/leader-skill-profile.ts b/game/leader-skill-profile.ts index cf4d3530..2b8f54a7 100644 --- a/game/leader-skill-profile.ts +++ b/game/leader-skill-profile.ts @@ -50,6 +50,23 @@ function nativeLeaderSkillModulesSupported(game: LeaderSkillProfile): boolean { return true; } +/** Standalone E3 native predicates retain their own roster, Duke and assassination limits. */ +function e3LeaderSkillModulesSupported(game: LeaderSkillProfile): boolean { + if (noOtherLeaderSkillModules(game)) return true; + const players = game.players; + if ((!game.techTokens && !game.strongholdCards) || !players || + players.length < (game.techTokens ? 3 : 2) || players.length > 6 || + (game.strongholdCards && game.advanced !== true) || + game.homeworlds || game.nexusCards || game.discoveries || game.discoveryEnabled || + game.ecazTreachery || game.semutaPreview || 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; + return true; +} + /** Classic three-to-six-seat Skills/Tech composition; native and other modules stay separate. */ export function classicTechLeaderSkillsProfile(game: LeaderSkillProfile): boolean { const players = game.players; @@ -71,10 +88,11 @@ export function classicTechLeaderSkillsProfile(game: LeaderSkillProfile): boolea return true; } -/** Original Advanced Strongholds, with optional Tech, for classic or supported E1/E2 skills. */ +/** Original Advanced Strongholds, with optional Tech, for classic or supported native skills. */ export function strongholdLeaderSkillsProfile(game: LeaderSkillProfile): boolean { if (!game.strongholdCards || game.advanced !== true) return false; - if (game.expansions.length) return advancedNativeLeaderSkillsProfile(game); + if (game.expansions.length) return advancedNativeLeaderSkillsProfile(game) || + advancedMoritaniLeaderSkillsProfile(game) || nativeEcazLeaderSkillsProfile(game); const players = game.players; if (!players || players.length < (game.techTokens ? 3 : 2) || players.length > 6 || game.homeworlds || game.nexusCards || game.discoveries || game.discoveryEnabled || @@ -96,7 +114,7 @@ export function strongholdLeaderSkillsProfile(game: LeaderSkillProfile): boolean /** Bounded integration of the ordinary Moritani roster, not Advanced assassination. */ export function basicMoritaniLeaderSkillsProfile(game: LeaderSkillProfile): boolean { return game.advanced === false && game.expansions.length === 1 && - game.expansions[0] === 'ecaz' && noOtherLeaderSkillModules(game) && + game.expansions[0] === 'ecaz' && e3LeaderSkillModulesSupported(game) && !!game.players?.some(player => player.faction === 'moritani') && game.players.every(player => player.faction === 'moritani' || FACTIONS.some(faction => faction.id === player.faction && faction.expansion === 'base')); @@ -107,7 +125,7 @@ export function advancedMoritaniLeaderSkillsProfile(game: LeaderSkillProfile): b const players = game.players; if (game.advanced !== true || game.expansions.length !== 1 || game.expansions[0] !== 'ecaz' || !players || players.length < 2 || players.length > 6 || - !noOtherLeaderSkillModules(game) || game.semutaPreview || game.advancedPreview || + !e3LeaderSkillModulesSupported(game) || game.semutaPreview || game.advancedPreview || game.kullPreview || game.nexusKullPreview || game.guildBetrayalPreview || game.richeseBetrayalPreview || game.nexusIxianReplacementPreview || game.nexusIxianBetrayalPreview || game.nexusHarkonnenBetrayalPreview) return false; @@ -132,7 +150,7 @@ export function nativeEcazLeaderSkillsProfile(game: LeaderSkillProfile): boolean const players = game.players; if (typeof game.advanced !== 'boolean' || game.expansions.length !== 1 || game.expansions[0] !== 'ecaz' || !players || players.length < 2 || players.length > 6 || - !noOtherLeaderSkillModules(game) || game.semutaPreview || game.moritaniAssassinatePreview || + !e3LeaderSkillModulesSupported(game) || game.semutaPreview || game.moritaniAssassinatePreview || game.advancedPreview || game.kullPreview || game.nexusKullPreview || game.guildBetrayalPreview || game.richeseBetrayalPreview || game.nexusIxianReplacementPreview || game.nexusIxianBetrayalPreview || @@ -248,7 +266,7 @@ export function nativeExpansionLeaderSkillsProfile(game: LeaderSkillProfile): bo advancedMoritaniLeaderSkillsProfile(game) || nativeEcazLeaderSkillsProfile(game); } -/** Only the original eligible E1/E2 native families gain the Tech combination. */ +/** Only the original eligible native skill families gain the Tech combination. */ export function nativeTechLeaderSkillsProfile(game: LeaderSkillProfile): boolean { return !!game.techTokens && nativeExpansionLeaderSkillsProfile(game); } diff --git a/game/reference.ts b/game/reference.ts index 26061df1..d242082a 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -3181,6 +3181,7 @@ export const RULE_TOPICS: RuleTopic[] = [ '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 original standalone Ecaz or Moritani skill roster additionally supports these Advanced combinations with the exact ecaz deck, original five-disc training and native exclusions. Normal Warmaster one on a temporary Duke composes Carthag Shield without granting trained three; normal winner Suk precedes unused-disc Moritani assassination, printed bounty, retained winner Tech and one private Mentat replacement. E3 pairs, mixtures and allied Occupy skills stay 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: @@ -3198,7 +3199,7 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Implementation', status: 'Partial', detail: - '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.', + 'All six local effects/end-Mentat custody are integrated. Fresh Advanced classic/supported native skill rosters, including standalone Ecaz/Moritani, compose all14 skills and optional original Tech. Original subsidy, physical rescue, native income/Duke/assassination and reward order remain; full combinations stay open.', evidence: [ 'game/stronghold-cards.ts', 'game/stronghold-battle.ts', @@ -3233,12 +3234,14 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Verification', status: 'Partial', detail: - '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.', + 'Dated standalone and E1/E2 Skills/Stronghold cases remain. Standalone E3 adds15 meaningful cases,152 affected, types/lint/build, nine actual programs and eight E3 games (1531 actions/no rejection,38 JSON continuations); the expanded47-game batch completes13936 actions/358 continuations. Human390px normal Duke/Carthag poison survival and Guild Suk before unused-disc assassination preserve original cards/Tech/income/replacement 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', + 'tests/ecaz-skills-modules-runtime.test.ts', + 'tests/moritani-skills-modules-runtime.test.ts', ], }, ], @@ -4495,6 +4498,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Original paid Ambassador placement and entrant reactions keep the exact token, training and movement suffix. Emperor Ambassador grants five bank spice; it is income, not a qualifying bank payment from another player for Banker.', 'A temporarily acquired Duke has printed strength six. A different living face-up Warmaster may give him only the normal one-point bonus with a physical Worthless card, never the trained-disc three. Surviving release or actual Duke death leaves the native trainer and card intact; bounty remains printed six.', 'Ecaz-only paid five-spice Duke revival retains the same living set-aside disc under the earlier selected interpretation. These paths keep an existing native skill and do not authorize shared-Duke assignment. Advanced Harkonnen, combined Occupy skills and other module/ruling boundaries remain explicit.', + 'Fresh standalone Ecaz skill tables may preserve original Tech Tokens in Basic/Advanced with three through six seats, and Stronghold Cards in Advanced with two through six, optionally both. Original five-disc training, Ambassador/Duke acquisition, local held-card protection or income, Suk rescue and mandatory winner Tech remain. A native face-up Warmaster gives temporary Duke only normal one; Carthag physical Shield can prevent poison. Original paid five-spice Duke revival remains living and set aside, not reassigned. Advanced Harkonnen, allied Occupy skills, pairs, mixtures and other guards stay unchanged.', ], related: ['leader-skills', 'duke-vidal', 'ecaz-ambassadors', 'spice-banker-income', 'implementation-checklist'], checklist: [ @@ -4541,6 +4545,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Connected movement, shipping, payment and battle skills use the same controls and legal quotes as the base profile. Terror Assassination returns a killed leader’s exact skill once; a later own-leader revival offers the normal private optional replacement. Sabotage affects Treachery Cards, not skills.', 'An Enemy of My Enemy alliance preserves each faction’s assignments. A losing ally cannot retain a used Planetologist Special or the Worthless card used for a Diplomat copy, because those cards must be discarded.', 'Basic Moritani with base opponents keeps its existing entry, including Harkonnen. Fresh Advanced Moritani plus classic opponents except Harkonnen uses the ecaz deck and the original assassination preview with all fourteen skills. Winner Suk rescue and Rihani choice finish before post-loss assassination, without replaying casualties. The actual winning disc remains excluded; another eligible trained disc returns its skill once and pays only printed bounty. Normal traitor revelation forfeits assassination for the whole game. Ecaz/Duke skill assignment, other native families, optional modules and public starts remain separate.', + 'The original standalone skill profile may additionally preserve Tech Tokens in Basic/Advanced three-through-six-seat tables and Stronghold Cards in Advanced two-through-six, optionally both. Actual normal winner Suk rescue finishes before unused trained-disc assassination and printed bounty; original winner cards/Tech, losing Tuek earnings, support and one real Mentat replacement remain. Ordinary Collection is separate. E3 pairs, mixtures, allied Occupy skills and other overlays remain guarded; public starts and save conversion stay excluded.', ], related: ['leader-skills', 'expansion-faction-games', 'implementation-checklist'], checklist: [ @@ -4592,8 +4597,8 @@ export const RULE_TOPICS: RuleTopic[] = [ steps: [ '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.', + 'Fresh three-through-six-seat Basic/Advanced development tables may compose all fourteen Skills with original Tech Tokens: classic factions, supported E1/E2 native families, or standalone Ecaz/Moritani with the exact ecaz deck and original roster exclusions. Starting cards/cache, skill/Traitor choices and printed/first-Storm assignment stay genuine. Existing leader-skills/banker-income entry preserves unused selected tokens; mixed E3 families, other overlays, public activation and saved-game conversion remain separate.', + 'Fresh Advanced classic or supported native two-through-six-seat skill tables may retain six original Stronghold Cards, with Tech at three or more seats. Original end-Mentat/local held effects remain. Arrakeen changes actual payer cost, not rescue or Banker eligibility; native CHOAM income retains bank support. Physical defenses, normal disciplines, holder ties and original dial/Worthless earnings remain. Standalone Ecaz Duke and Moritani assassination compose original rules; mixed E3, allied Occupy skills, copied Diplomat defenses, active skill/Stone and other guards stay separate.', '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.', @@ -4621,7 +4626,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, 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'] }, + { area:'Verification',status:'Partial',detail:'Dated classic/native Skills-Tech and E1/E2 Stronghold evidence remains in the existing runtime. Standalone E3 adds15 meaningful cases,152 affected, types/lint/build, nine actual programs and eight E3 games (1531 actions/no rejection,38 JSON continuations); expanded47-game batch13936 actions/358 continuations. Human390px normal Warmaster1/Duke6/Carthag Shield wins7–6 and releases the living Duke; normal Guild Suk precedes unused Master3 death/printed bounty, original winner Tech and one private Caid at Mentat. Actual physical force/card outcomes and separately quoted Collection remain 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','tests/ecaz-skills-modules-runtime.test.ts','tests/moritani-skills-modules-runtime.test.ts','docs/LEADER_SKILLS_RUNTIME.md'] }, ], }, { diff --git a/tests/ecaz-skills-modules-runtime.test.ts b/tests/ecaz-skills-modules-runtime.test.ts new file mode 100644 index 00000000..056ea99c --- /dev/null +++ b/tests/ecaz-skills-modules-runtime.test.ts @@ -0,0 +1,252 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { DUKE_VIDAL_ID } from '../game/duke-vidal'; +import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards'; +import { ownedTech } from '../game/tech-tokens'; +import { quoteSpiceCollection } from '../game/board-resolution-quote'; +import { + advanceEcazSkillsModules as advance, assertEcazSkillsModulesCustody as custody, + createEcazSkillsModulesFixture as fixture, ecazSkillsModulesAction as act, + ecazSkillsModulesPlayer as player, ecazSkillsModulesTrainer as trainer, + reloadEcazSkillsModules as reload, revealEcazSkillsModulesBattle as reveal, + settleEcazSkillsModulesBattle as settle, +} from './fixture-ecaz-skills-modules'; + +function reject(game: Game, actor: string, action: Action): void { + const before = structuredClone(game); + assert.throws(() => applyAction(game, actor, action)); + assert.deepEqual(game, before, 'A rejected combined-module action must preserve the real source.'); +} +function policy(game: Game, actor: string, difficulty: typeof DIFFICULTIES[number]): Action { + const view = viewGame(reload(game), actor); + view.players.find(p => p.id === actor)!.bot = difficulty; + const action = botActions(view)[0]; assert.ok(action); return action; +} +function collection(game: Game, actor: 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 === actor).reduce((sum, r) => sum + r.strongholds + r.collected, 0); +} + +void test('original Ecaz normal and selected Suk rescue physical ordinary losses before winner cards and earned Tech', () => { + for (const rules of ['basic', 'advanced'] as const) { + for (const stronghold of rules === 'advanced' ? [false, true] : [false]) { + for (const band of ['normal', 'skilled'] as const) { + const f = fixture({ rules, tech: true, stronghold, band }); + const assigned = structuredClone(trainer(f.game, f.owner)); + const before = reveal(f), wallet = player(before, f.owner).spice, reserves = player(before, f.owner).reserves; + const defeatedTokens = ownedTech(before.techTokens, f.opponent); + assert.equal(defeatedTokens.length, 1, 'Actual three-seat first Storm assigns one physical token to each original seat.'); + const supportCost = rules === 'advanced' ? stronghold ? 3 : 5 : 0; + let game = settle(before, band === 'skilled' ? 'suk' : 'cards'); + assert.equal(game.lastBattleContext!.winner, f.owner); + assert.equal(player(game, f.owner).spice, wallet - supportCost); + assert.equal(game.techTokens![defeatedTokens[0]].owner, f.opponent, 'Neither committed rescue nor optional card retention steals technology early.'); + if (band === 'skilled') { + assert.equal(game.decision?.kind, 'sukRescue'); + if (game.decision?.kind !== 'sukRescue') throw Error('Expected physical trained-disc Suk rescue.'); + assert.deepEqual(game.pendingSukRescue!.losses, { normal: 5, elite: 0, paidNormal: rules === 'advanced' ? 5 : 0, paidElite: 0 }); + reject(game, f.opponent, { type: 'decision', event: game.decision.event, choice: 0 }); + reject(game, f.owner, { type: 'decision', event: `${game.decision.event}-stale`, choice: 0 }); + reject(game, f.owner, { type: 'decision', event: game.decision.event, choice: game.decision.options.length }); + const choice = game.decision.options.findIndex(o => o.normal === 3 && o.elite === 0 && o.kept?.key === f.location); + assert.ok(choice >= 0); + game = settle(act(game, f.owner, { type: 'decision', event: game.decision.event, choice }), 'cards'); + assert.equal(player(game, f.owner).forces[f.location], 4); + assert.equal(player(game, f.owner).reserves, reserves + 2); + assert.equal(player(game, f.owner).tanks, 2); + assert.equal(game.pendingSukRescue, null); + assert.equal(game.lastBattleContext!.sukRescue!.completed, true); + } else { + assert.equal(player(game, f.owner).forces[f.location], 3); + assert.equal(player(game, f.owner).reserves, reserves + 1); + assert.equal(player(game, f.owner).tanks, 4); + assert.equal(game.pendingSukRescue, null); + } + assert.equal(game.decision?.kind, 'battleCards'); + assert.equal(game.decision?.player, f.owner); + reject(game, f.owner, { type: 'decision', token: defeatedTokens[0] }); + assert.equal(game.techTokens![defeatedTokens[0]].owner, f.opponent); + game = settle(act(game, f.owner, { type: 'decision', discard: [] })); + assert.equal(game.techTokens![defeatedTokens[0]].owner, f.owner); + assert.equal(player(game, f.opponent).tanks, 8); + assert.deepEqual(trainer(game, f.owner), assigned); + custody(game); + } + } + } +}); + +void test('all four real legal policies rescue trained native Ecaz counters and resolve its physical card/Tech continuation', () => { + const f = fixture({ tech: true, stronghold: true, band: 'skilled' }); + const revealed = reveal(f), source = settle(revealed, 'suk'); + assert.equal(source.decision?.kind, 'sukRescue'); + for (const difficulty of DIFFICULTIES) { + const rescued = settle(act(source, f.owner, policy(source, f.owner, difficulty)), 'cards'); + assert.equal(rescued.pendingSukRescue, null); + assert.equal(player(rescued, f.owner).tanks, 2); + assert.equal(player(rescued, f.owner).reserves, player(revealed, f.owner).reserves + 2); + assert.equal(player(rescued, f.owner).forces[f.location], 4); + assert.equal(player(rescued, f.owner).spice, player(revealed, f.owner).spice - 3); + assert.equal(rescued.decision?.kind, 'battleCards'); + const cardAction = policy(rescued, f.owner, difficulty); + const finished = settle(act(rescued, f.owner, cardAction)); + assert.equal(finished.battle, null); + assert.equal(finished.lastBattleContext!.winner, f.owner); + for (const token of ownedTech(revealed.techTokens, f.opponent)) assert.equal(finished.techTokens![token].owner, f.owner); + assert.equal(player(finished, f.owner).tanks, 2); + custody(finished); + } +}); + +void test('real Arrakeen subsidy and separately opted-in normal Banker count only the opposing paid Bank leg', () => { + const f = fixture({ kind: 'income', band: 'normal', bankerIncome: true, tech: true, stronghold: true, opponentDial: 4, defense: true }); + const before = reveal(f), sources = before.spiceBankerIncome!.sources.length; + const game = settle(before, 'cards'); + assert.equal(game.lastBattleContext!.winner, f.owner); + assert.equal(player(game, f.owner).spice, player(before, f.owner).spice - 3); + assert.equal(player(game, f.opponent).spice, player(before, f.opponent).spice - 4); + const payments = game.spiceBankerIncome!.sources.slice(sources).filter(r => r.source.kind === 'battle-support'); + assert.deepEqual(payments.map(r => r.source.bankLegs), [ + [{ payer: before.battle!.attacker, amount: before.battle!.attacker === f.owner ? 3 : 4 }], + [{ payer: before.battle!.defender, amount: before.battle!.defender === f.owner ? 3 : 4 }], + ]); + assert.deepEqual(payments.filter(r => r.grant).map(r => ({ owner: r.grant!.owner, amount: r.grant!.amount })), [{ owner: f.owner, amount: 1 }]); + const finished = settle(game); + const beforeMentat = advance(finished, g => g.phase === 7 && !g.response && !g.phaseOpening && !g.decision); + const wallet = player(beforeMentat, f.owner).spice; + const mentat = advance(beforeMentat, g => g.phase === 8 && !g.response && !g.phaseOpening && !g.decision); + assert.equal(player(mentat, f.owner).spice, wallet + 1, 'The one front-of-shield grant becomes wallet spice only at actual Mentat.'); + custody(mentat); +}); + +void test('original Ambassador Duke6 receives normal Warmaster1, never the separate trained-disc3, and survives release with native assignment intact', () => { + for (const rules of ['basic', 'advanced'] as const) { + for (const band of ['normal', 'skilled'] as const) { + const f = fixture({ rules, kind: 'duke', tech: true, stronghold: rules === 'advanced', band }); + assert.ok(f.acquisition); + assert.equal(player(f.acquisition.after, f.owner).spice, player(f.acquisition.before, f.owner).spice - 1); + assert.equal(player(f.acquisition.after, f.opponent).spice, player(f.acquisition.before, f.opponent).spice - 1); + assert.equal(f.acquisition.after.dukeVidal!.source, 'ecaz'); + const assigned = structuredClone(trainer(f.game, f.owner)), native = structuredClone(player(f.game, f.owner).leaders); + const before = reveal(f), game = settle(before); + assert.equal(game.lastBattleContext!.winner, band === 'normal' ? f.owner : f.opponent); + assert.equal(game.dukeVidal!.leader.dead, false); + assert.equal(game.dukeVidal!.leader.usedAt, f.territory); + assert.deepEqual([game.dukeVidal!.controller, game.dukeVidal!.source, game.dukeVidal!.acquiredTurn], [null, null, null]); + assert.deepEqual(trainer(game, f.owner), assigned); + assert.deepEqual(player(game, f.owner).leaders, native); + assert.ok(viewGame(game, f.owner).leaderSkills!.eligibleLeaders.every(l => l.id !== DUKE_VIDAL_ID)); + custody(game); + } + const f = fixture({ rules, kind: 'duke', tech: true, stronghold: rules === 'advanced', opponentDial: 3 }); + const game = settle(reveal(f)); + assert.equal(game.lastBattleContext!.winner, f.opponent, 'Duke6+normal1 loses to printed5+3; borrowing the selected trainer bonus would incorrectly win.'); + assert.equal(game.dukeVidal!.leader.dead, false); + custody(game); + } +}); + +void test('actual retained Carthag adds Snooper to real Duke Shield, while Tuek pays for played Worthless even on printed6 death and paid5 revival', () => { + const defended = fixture({ kind: 'duke', strongholdKind: 'carthag', poison: true, defense: true }); + const safe = settle(reveal(defended)); + assert.equal(safe.lastBattleContext!.winner, defended.owner); + assert.equal(safe.dukeVidal!.leader.dead, false, 'Retained Carthag adds the printed Snooper property to the original physical Shield.'); + custody(safe); + const f = fixture({ kind: 'duke', strongholdKind: 'tueks_sietch', poison: true, opponentDial: 0 }); + const assigned = structuredClone(trainer(f.game, f.owner)), native = structuredClone(player(f.game, f.owner).leaders); + const before = reveal(f), dead = settle(before); + assert.equal(dead.lastBattleContext!.winner, f.opponent); + assert.equal(dead.dukeVidal!.leader.dead, true); + assert.equal(dead.dukeVidal!.leader.deaths, 1); + assert.equal(player(dead, f.owner).spice, player(before, f.owner).spice + 2 + collection(dead, f.owner), 'Tuek counts the real Worthless even though Duke dies and Ecaz loses.'); + assert.equal(player(dead, f.opponent).spice, player(before, f.opponent).spice + 6 + collection(dead, f.opponent), 'Only the printed shared-disc6 is paid, not normal7 or trained9.'); + assert.deepEqual(player(dead, f.owner).leaders, native); + assert.deepEqual(trainer(dead, f.owner), assigned); + const skillInventory = structuredClone(dead.leaderSkills); + const revival = advance(dead, g => g.turn > dead.turn && g.phase === 4 && !g.response && !g.phaseOpening && !g.decision); + const option = viewGame(revival, f.owner).revival.leaders.find(l => l.id === DUKE_VIDAL_ID)!; + assert.equal(option.cost, 5); assert.equal(option.normalCost, 5); assert.equal(option.affordable, true); + reject(revival, f.opponent, { type: 'reviveLeader', leader: DUKE_VIDAL_ID }); + const funds = player(revival, f.owner).spice; + let revived = act(revival, f.owner, { type: 'reviveLeader', leader: DUKE_VIDAL_ID }); + revived = advance(revived, g => !g.response && !g.pendingRevival); + assert.equal(player(revived, f.owner).spice, funds - 5); + assert.equal(player(revived, f.owner).leaderRevived, true); + assert.equal(revived.dukeVidal!.leader.dead, false); + assert.equal(revived.dukeVidal!.leader.deaths, 1); + assert.deepEqual([revived.dukeVidal!.controller, revived.dukeVidal!.source, revived.dukeVidal!.acquiredTurn], [null, null, null]); + assert.deepEqual(revived.leaderSkills, skillInventory); + assert.deepEqual(trainer(revived, f.owner), assigned); + assert.equal(revived.leaderSkills!.offers[f.owner], undefined); + custody(revived); +}); + +void test('fresh human IDs retain original module acquisition chronology; insufficient Tech seats and Advanced Hark reject without conversion', () => { + const initial = createGame('HUMANECAZMODULES', newPlayer('human-ecaz', 'Human Ecaz', 'ecaz'), true, ['ecaz']); + joinGame(initial, newPlayer('human-emperor', 'Human Emperor', 'emperor')); + joinGame(initial, newPlayer('human-atreides', 'Human Atreides', 'atreides')); + const snapshot = structuredClone(initial), f = fixture({ initial, kind: 'suk' }); + assert.deepEqual(initial, snapshot); + assert.deepEqual(f.game.players.map(p => p.id), snapshot.players.map(p => p.id)); + assert.equal(f.owner, 'human-ecaz'); assert.equal(f.opponent, 'human-emperor'); + assert.equal(f.beforeFirstMentat.strongholdCards!.owners.arrakeen, null); + const claimed = act(f.beforeFirstMentat, f.firstMentatStep.actor, f.firstMentatStep.action); + assert.equal(claimed.turn, 2); assert.equal(claimed.strongholdCards!.claimedTurn, 1); + assert.equal(claimed.strongholdCards!.owners.arrakeen, f.owner); + const physicalSkills = [...f.offered.leaderSkills!.deck, ...Object.values(f.offered.leaderSkills!.offers).flatMap(o => o.cards)]; + assert.deepEqual(physicalSkills.sort(), LEADER_SKILL_CARDS.map(c => c.id).sort()); + assert.equal(f.offered.players.find(p => p.id === f.owner)!.reserves, 20); + const two = createGame('ECAZTWOTECH', newPlayer('e', 'Ecaz', 'ecaz'), true, ['ecaz']); + joinGame(two, newPlayer('p', 'Emperor', 'emperor')); + let unsupported = applyAction(two, two.host, { type: 'techTokens', enabled: true }); + for (const p of unsupported.players) unsupported = applyAction(unsupported, p.id, { type: 'ready' }); + const before = structuredClone(unsupported); + assert.throws(() => initializeLeaderSkillsGameForAudit(unsupported)); assert.deepEqual(unsupported, before); + const hark = createGame('ECAZHARK', newPlayer('e', 'Ecaz', 'ecaz'), true, ['ecaz']); + joinGame(hark, newPlayer('p', 'Emperor', 'emperor')); joinGame(hark, newPlayer('h', 'Harkonnen', 'harkonnen')); + let advancedHark = applyAction(hark, hark.host, { type: 'techTokens', enabled: true }); + for (const p of advancedHark.players) advancedHark = applyAction(advancedHark, p.id, { type: 'ready' }); + const harkBefore = structuredClone(advancedHark); + assert.throws(() => initializeLeaderSkillsGameForAudit(advancedHark)); assert.deepEqual(advancedHark, harkBefore); + custody(settle(reveal(f))); +}); + +void test('real two-seat Stronghold and six-seat Basic/Advanced Tech boundaries preserve native Emperor5 and Fremen3 inventories', () => { + const two = createGame('ECAZTWOSTRONG', newPlayer('two-ecaz', 'Ecaz', 'ecaz'), true, ['ecaz']); + joinGame(two, newPlayer('two-emperor', 'Emperor', 'emperor')); + const twoCase = fixture({ initial: two, tech: false, stronghold: true }); + const twoDone = settle(reveal(twoCase)); + assert.equal(twoDone.lastBattleContext!.winner, twoCase.owner); + assert.equal(player(twoDone, twoCase.owner).tanks, 2); + assert.equal(twoCase.afterFirstMentat.strongholdCards!.owners.arrakeen, twoCase.owner); + custody(twoDone); + for (const rules of ['basic', 'advanced'] as const) { + const six = createGame(`ECAZSIX${rules}`, newPlayer('six-ecaz', 'Ecaz', 'ecaz'), rules === 'advanced', ['ecaz']); + for (const faction of ['emperor', 'atreides', 'guild', 'fremen', 'beneGesserit'] as const) + joinGame(six, newPlayer(`six-${faction}`, faction, faction)); + const f = fixture({ initial: six, rules, tech: true, stronghold: rules === 'advanced' }); + const game = settle(reveal(f)); + assert.equal(game.lastBattleContext!.winner, f.owner); + assert.equal(player(game, f.owner).forces[f.location], 4); + assert.equal(player(game, f.owner).tanks, 2); + if (rules === 'advanced') { + assert.equal(game.players.find(p => p.faction === 'fremen')!.elites!.reserves, 3); + assert.equal(game.players.find(p => p.faction === 'emperor')!.elites!.reserves, 5); + } else assert.ok(game.players.every(p => !p.elites)); + assert.deepEqual(game.players.map(p => p.id), six.players.map(p => p.id)); + custody(game); + } + const basicHark = createGame('ECAZBASICHARK', newPlayer('basic-ecaz', 'Ecaz', 'ecaz'), false, ['ecaz']); + joinGame(basicHark, newPlayer('basic-emperor', 'Emperor', 'emperor')); + joinGame(basicHark, newPlayer('basic-hark', 'Harkonnen', 'harkonnen')); + const allowed = fixture({ initial: basicHark, rules: 'basic', tech: true, stronghold: false }); + const done = settle(reveal(allowed)); + assert.equal(done.lastBattleContext!.winner, allowed.owner); + assert.equal(player(done, allowed.owner).tanks, 2); + assert.equal(player(done, 'basic-hark').traitors.length, 4); + custody(done); +}); diff --git a/tests/fixture-ecaz-skills-modules.ts b/tests/fixture-ecaz-skills-modules.ts new file mode 100644 index 00000000..d0135e72 --- /dev/null +++ b/tests/fixture-ecaz-skills-modules.ts @@ -0,0 +1,254 @@ +import assert from 'node:assert/strict'; +import { applyAction, createGame, handLimit, joinGame, newPlayer, viewGame, type Action, type Game, type Player } from '../game/engine'; +import { territory } from '../game/board'; +import { leaders, treacheryDeck, type Card } from '../game/cards'; +import type { FactionId } from '../game/catalog'; +import { DUKE_VIDAL_ID } from '../game/duke-vidal'; +import { validateAmbassadors } from '../game/ecaz-ambassadors'; +import type { LeaderSkillId } from '../game/leader-skill-cards'; +import { validateLeaderSkills } from '../game/leader-skills'; +import type { StrongholdId } from '../game/stronghold-cards'; +import { traitorDeck } from '../game/traitors'; +import { initializeAdvancedNativeSkillsSetup } from './fixture-advanced-native-skills'; +import { nextStrongholdFactionsNativeStep, type StrongholdFactionsNativeStep } from './fixture-stronghold-factions'; + +export type EcazSkillsModulesOptions = { + /** Only an original undealt authenticated lobby; preserve its IDs and order. */ + initial?: Game; + rules?: 'basic' | 'advanced'; + tech?: boolean; + stronghold?: boolean; + kind?: 'suk' | 'duke' | 'income'; + skill?: LeaderSkillId; + band?: 'normal' | 'skilled'; + strongholdKind?: Exclude; + /** Separate existing native Banker income opt-in, not a skill-module default. */ + bankerIncome?: boolean; + poison?: boolean; + defense?: boolean; + opponentDial?: number; +}; +export type EcazSkillsModulesFixture = { + initial: Game; offered: Game; afterSetup: Game; + beforeFirstMentat: Game; firstMentatStep: StrongholdFactionsNativeStep; afterFirstMentat: Game; + acquisition: { before: Game; after: Game; actions: StrongholdFactionsNativeStep[] } | null; + beforeBattle: Game; game: Game; + owner: string; opponent: string; trainer: string; + territory: Exclude; location: string; + plans: StrongholdFactionsNativeStep[]; actions: StrongholdFactionsNativeStep[]; staging: string[]; +}; +export const reloadEcazSkillsModules = (g: Game): Game => JSON.parse(JSON.stringify(g)) as Game; +export const ecazSkillsModulesAction = (g: Game, actor: string, action: Action): Game => + applyAction(reloadEcazSkillsModules(g), actor, action); +export function ecazSkillsModulesPlayer(game: Game, actor: string): Player { + const player = game.players.find(p => p.id === actor); assert.ok(player); return player; +} +export const ecazSkillsModulesTrainer = (game: Game, owner: string) => { + const assignment = game.leaderSkills!.assignments.find(a => a.owner === owner); assert.ok(assignment); return assignment; +}; +export function assertEcazSkillsModulesCustody(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) { + assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((a, b) => a + b, 0), p.faction === 'fremen' ? 3 : 5, p.faction); + for (const [location, n] of Object.entries(p.elites.forces)) assert.ok(n <= (p.forces[location] ?? 0)); + } + assert.deepEqual(p.leaders.map(l => l.id).sort(), leaders(p.faction).map(l => l.id).sort()); + } + const physical = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand), + ...(game.auction?.cards.slice(game.auction.index) ?? [])]; + assert.deepEqual(physical.map(c => c.id).sort(), treacheryDeck(['ecaz']).map(c => c.id).sort()); + const traitors = [...game.traitorReserve!, ...game.players.flatMap(p => [...p.traitors, ...p.traitorChoices]), + ...(game.ecazLoyalty?.card ? [game.ecazLoyalty.card] : [])]; + assert.deepEqual(traitors.sort(), traitorDeck(game.players).sort()); + assert.equal(game.players.find(p => p.faction === 'ecaz')!.leaders.length, 5); + assert.equal(game.dukeVidal!.leader.id, DUKE_VIDAL_ID); + assert.equal(game.dukeVidal!.leader.strength, 6); + assert.ok(game.leaderSkills!.assignments.every(a => a.leader !== DUKE_VIDAL_ID)); + validateAmbassadors(game.ecazAmbassadors!); +} +const clean = (g: Game) => !g.response && !g.phaseOpening && !g.decision; +/** Responses always precede decisions: consume the original persisted pass queue. */ +export function nextEcazSkillsModulesStep(game: Game, hide = true): StrongholdFactionsNativeStep | null { + if (game.response || game.phaseOpening || game.pendingTreacheryDiscard) + return nextStrongholdFactionsNativeStep(game); + const d = game.decision; + if (d?.kind === 'ecazPlacement' || d?.kind === 'ecazAmbassador') + return { actor: d.player, action: { type: 'decision', decline: true } }; + if (d?.kind === 'leaderSkillVisibility') return { actor: d.player, + action: { type: 'leaderSkillVisibility', event: d.event, hide } }; + if (d?.kind === 'mentatQuestion') return { actor: d.player, action: { type: 'decision', event: d.event, decline: true } }; + if (d?.kind === 'rihani') return { actor: d.player, action: { type: 'decision', event: d.event, draw: false } }; + if (d?.kind === 'sukRescue') { + const maximum = Math.max(...d.options.map(o => o.normal + o.elite)); + const choice = d.options.findIndex(o => o.normal + o.elite === maximum && o.kept?.kind === 'normal'); + return { actor: d.player, action: { type: 'decision', event: d.event, + choice: choice >= 0 ? choice : d.options.findIndex(o => o.normal + o.elite === maximum) } }; + } + if (d?.kind === 'techToken') return { actor: d.player, action: { type: 'decision', token: d.choices[0] } }; + return nextStrongholdFactionsNativeStep(game); +} +function step(game: Game, next: StrongholdFactionsNativeStep, actions?: StrongholdFactionsNativeStep[]): Game { + actions?.push(structuredClone(next)); + return ecazSkillsModulesAction(game, next.actor, next.action); +} +export function advanceEcazSkillsModules(state: Game, until: (g: Game) => boolean, + actions?: StrongholdFactionsNativeStep[], hide = true, 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 = nextEcazSkillsModulesStep(game, hide); assert.ok(next, 'An original private plan needs its actual owner.'); + if (game.turn === 1 && next.action.type === 'stormDial') next.action.amount = firstDial; + game = step(game, next, actions); + } + throw Error('Original Ecaz skill/module chronology did not reach its 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): void { + const p = ecazSkillsModulesPlayer(game, actor); assert.ok(p.reserves >= count); + p.reserves -= count; p.forces[location] = (p.forces[location] ?? 0) + count; +} +function take(game: Game, actor: string, predicate: (c: Card) => boolean, staging: string[]): Card { + const p = ecazSkillsModulesPlayer(game, actor), held = p.hand.find(predicate); + if (held) return held; + const index = game.deck.findIndex(predicate); assert.ok(index >= 0 && p.hand.length < handLimit(p)); + const card = game.deck.splice(index, 1)[0]; p.hand.push(card); + staging.push(`Conserved ${card.id}: original remaining Ecaz33 deck to ${actor}'s hand.`); return card; +} +/** Original Ecaz33/all14 setup and first Storm assignment, then actual first END + * Mentat. Only physical offer order, conserved native cards/board and unplayed + * non-worm Spice Cards are staged. No phase, turn, storm, wallet, owner or receipt + * is assigned, and no allied Occupy, E3 pair or overlay module is admitted. */ +export function createEcazSkillsModulesFixture(options: EcazSkillsModulesOptions = {}): EcazSkillsModulesFixture { + const rules = options.rules ?? (options.initial?.advanced === false ? 'basic' : 'advanced'); + const tech = options.tech ?? true, stronghold = options.stronghold ?? rules === 'advanced'; + const kind = options.kind ?? 'suk', band = options.band ?? (kind === 'suk' ? 'skilled' : 'normal'); + const requested = options.skill ?? (kind === 'duke' ? 'warmaster' : kind === 'income' ? 'spice-banker' : 'suk-graduate'); + const strongholdKind = options.strongholdKind ?? 'arrakeen'; + const location = `${strongholdKind}:${territory(strongholdKind).sectors[0]}`; + let game = options.initial ? structuredClone(options.initial) + : createGame('ECAZSKILLSMODULES', newPlayer('ecaz', 'Ecaz', 'ecaz'), rules === 'advanced', ['ecaz']); + if (!options.initial) for (const faction of ['emperor', 'atreides'] as FactionId[]) joinGame(game, newPlayer(faction, faction, faction)); + assert.equal(game.status, 'lobby', 'Only the original undealt authenticated lobby may be continued.'); + assert.equal(game.advanced, rules === 'advanced'); assert.deepEqual(game.expansions, ['ecaz']); + assert.ok(game.players.length >= 2 && game.players.length <= 6 && (!tech || game.players.length >= 3)); + assert.ok(!stronghold || game.advanced); + assert.ok(game.players.every(p => p.faction === 'ecaz' || ['atreides', 'emperor', 'guild', 'fremen', 'beneGesserit', ...(game.advanced ? [] : ['harkonnen'])].includes(p.faction))); + const owner = game.players.find(p => p.faction === 'ecaz')?.id, opponent = game.players.find(p => p.faction === 'emperor')?.id; + assert.ok(owner && opponent); + if (!!game.techTokens !== tech) game = applyAction(game, game.host, { type: 'techTokens', enabled: tech }); + if (!!game.strongholdCards !== stronghold) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: stronghold }); + const initial = structuredClone(game), actions: StrongholdFactionsNativeStep[] = [], staging: string[] = []; + game = initializeAdvancedNativeSkillsSetup({ family: 'ecaz', rules, requestedSkill: requested, bankerIncome: options.bankerIncome, initial: game }); + 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], offer = game.leaderSkills!.offers[actor]; + const own = viewGame(game, actor).leaderSkills!; + const skill = actor === owner ? requested : offer.cards.find(c => !own.unavailableSkills?.[c]); + const eligible = new Set(own.eligibleLeaders.map(l => l.id)); + const leader = ecazSkillsModulesPlayer(game, actor).leaders.filter(l => eligible.has(l.id)).sort((a, b) => b.strength - a.strength)[0]; + assert.ok(skill && offer.cards.includes(skill) && !own.unavailableSkills?.[skill] && leader); + game = step(game, { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: leader.id } }, actions); + } else { const next = nextEcazSkillsModulesStep(game); assert.ok(next); game = step(game, next, actions); } + } + assert.equal(game.status, 'playing'); + const afterSetup = structuredClone(game), trainer = ecazSkillsModulesTrainer(game, owner).leader; + reserveBoard(game); + const blows = game.spiceDeck.filter(c => 'territory' in c).slice(0, 6); + for (const [index, card] of blows.entries()) game.spiceDeck.splice(index, 0, game.spiceDeck.splice(game.spiceDeck.indexOf(card), 1)[0]); + staging.push('After original setup, return every native board counter to its own reserve; order only six existing unplayed non-worm Spice Cards, not a claimed random history.'); + const firstDial = ['tueks_sietch', 'habbanya_ridge_sietch'].includes(strongholdKind) ? 4 : 0; + game = advanceEcazSkillsModules(game, g => g.phase === 8 && clean(g), actions, true, firstDial); + reserveBoard(game); place(game, owner, location, 1); + staging.push(`Before actual first END Mentat, transfer one Ecaz counter from its reserve to ${location}; no seat controls three holds.`); + let beforeFirstMentat: Game | undefined, firstMentatStep: StrongholdFactionsNativeStep | undefined; + for (let i = 0; game.turn === 1 && i < 200; i++) { + const next = nextEcazSkillsModulesStep(game); assert.ok(next); + const before = structuredClone(game); game = step(game, next, actions); + if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = next; } + } + assert.ok(beforeFirstMentat && firstMentatStep); + if (stronghold) assert.equal(game.strongholdCards!.owners[strongholdKind], owner); + const afterFirstMentat = structuredClone(game); + let acquisition: EcazSkillsModulesFixture['acquisition'] = null; + if (kind === 'duke') { + game = advanceEcazSkillsModules(game, g => g.phase === 4 && clean(g), actions); + reserveBoard(game); + game = advanceEcazSkillsModules(game, g => g.decision?.kind === 'ecazPlacement', actions); + const before = structuredClone(game), acquisitionActions: StrongholdFactionsNativeStep[] = []; + const token = game.ecazAmbassadors!.tokens.find(t => t.effect === 'ecaz')!; + game = step(game, { actor: owner, action: { type: 'decision', token: token.id, territory: strongholdKind } }, acquisitionActions); + game = advanceEcazSkillsModules(game, g => !g.response && g.decision?.kind === 'ecazPlacement', acquisitionActions); + game = step(game, { actor: owner, action: { type: 'decision', decline: true } }, acquisitionActions); + game = advanceEcazSkillsModules(game, g => g.phase === 5 && clean(g) && g.active === opponent, acquisitionActions); + game = step(game, { actor: opponent, action: { type: 'ship', territory: strongholdKind, sector: territory(strongholdKind).sectors[0], amount: 1 } }, acquisitionActions); + game = advanceEcazSkillsModules(game, g => !g.response && g.decision?.kind === 'ecazAmbassador', acquisitionActions); + assert.equal(viewGame(game, owner).ambassadorEntry!.dukeAcquisition!.blocked, null); + game = step(game, { actor: owner, action: { type: 'decision', event: game.pendingAmbassador!.event, + trigger: true, beneficiary: owner, choice: 'duke' } }, acquisitionActions); + assert.equal(game.dukeVidal!.controller, owner); + actions.push(...acquisitionActions); acquisition = { before, after: structuredClone(game), actions: acquisitionActions }; + staging.push('Original paid Ambassador placement and entrant shipment acquire the separate Duke6; no controller assignment or skill reassignment.'); + } else game = advanceEcazSkillsModules(game, g => g.phase === 5 && clean(g), actions); + reserveBoard(game); + for (const actor of [owner, opponent]) place(game, actor, location, 8); + for (const p of game.players) { game.deck.push(...p.hand); p.hand = []; } + staging.push(`Before real turn-two Movement completes, conserve eight ordinary counters per combatant at ${location}; return original hands to the same native deck before physical transfers.`); + const worthless = take(game, owner, c => c.kind === 'worthless', staging); + const shield = options.defense ? take(game, owner, c => c.kind === 'shield', staging) : undefined; + const poison = options.poison ? take(game, opponent, c => c.kind === 'poison', staging) : undefined; + game = advanceEcazSkillsModules(game, g => g.phase === 6 && clean(g), actions); + const beforeBattle = structuredClone(game); + assert.ok(game.active === owner || game.active === opponent); + game = step(game, { actor: game.active!, action: { type: 'chooseBattle', territory: strongholdKind, + target: game.active === owner ? opponent : owner } }, actions); + // The owner alone retains the requested visibility; every enemy skill is hidden. + for (let i = 0; !(game.battle && clean(game) && !game.battle.preparation && game.battle.preLeader?.closed !== false) && i < 200; i++) { + const next = nextEcazSkillsModulesStep(game); assert.ok(next); + if (next.action.type === 'leaderSkillVisibility' && next.actor === owner) next.action.hide = band === 'skilled'; + game = step(game, next, actions); + } + const enemy = ecazSkillsModulesPlayer(game, opponent); + const enemyTrainer = ecazSkillsModulesTrainer(game, opponent).leader; + const defender = enemy.leaders.filter(l => !l.dead && l.id !== enemyTrainer).sort((a, b) => + kind === 'duke' ? b.strength - a.strength : a.strength - b.strength)[0]; assert.ok(defender); + const ownLeader = kind === 'duke' ? DUKE_VIDAL_ID : band === 'skilled' ? trainer + : ecazSkillsModulesPlayer(game, owner).leaders.find(l => l.strength === 4 && l.id !== trainer)!.id; + const ownerDial = kind === 'duke' ? 0 : 5; + const opponentDial = options.opponentDial ?? (kind === 'duke' + ? game.battle!.attacker === owner ? 2 : 1 : kind === 'income' ? 1 : 0); + const plans: StrongholdFactionsNativeStep[] = [ + { actor: owner, action: { type: 'battlePlan', leader: ownLeader, dial: ownerDial, + support: game.advanced ? ownerDial : 0, weapon: worthless.id, defense: shield?.id } }, + { actor: opponent, action: { type: 'battlePlan', leader: defender.id, dial: opponentDial, + support: game.advanced ? opponentDial : 0, weapon: poison?.id } }, + ]; + assertEcazSkillsModulesCustody(game); + return { initial, offered, afterSetup, beforeFirstMentat, firstMentatStep, afterFirstMentat, acquisition, + beforeBattle, game, owner, opponent, trainer, territory: strongholdKind, location, plans, actions, staging }; +} +export function revealEcazSkillsModulesBattle(f: EcazSkillsModulesFixture): Game { + let game = reloadEcazSkillsModules(f.game); + for (const plan of f.plans) { + game = step(game, plan); + if (!game.battle?.revealed) game = advanceEcazSkillsModules(game, + g => clean(g) && !g.battle?.preparation && g.battle?.preLeader?.closed !== false); + } + assert.ok(game.battle?.revealed); return game; +} +export function settleEcazSkillsModulesBattle(game: Game, stop: 'suk' | 'cards' | 'tech' | 'complete' = 'complete'): Game { + return advanceEcazSkillsModules(game, g => + stop === 'suk' && g.decision?.kind === 'sukRescue' || + stop === 'cards' && g.decision?.kind === 'battleCards' || + stop === 'tech' && g.decision?.kind === 'techToken' || + !g.battle && clean(g) && !g.pendingTreacheryDiscard); +} diff --git a/tests/fixture-moritani-skills-modules.ts b/tests/fixture-moritani-skills-modules.ts new file mode 100644 index 00000000..8a9bbf3d --- /dev/null +++ b/tests/fixture-moritani-skills-modules.ts @@ -0,0 +1,250 @@ +import assert from 'node:assert/strict'; +import { + applyAction, createGame, joinGame, newPlayer, viewGame, + type Action, type Game, +} from '../game/engine'; +import type { Difficulty } from '../game/bot-profiles'; +import { botActions } from '../game/bots'; +import type { FactionId } from '../game/catalog'; +import type { Card } from '../game/cards'; +import { TERRITORIES, territory } from '../game/board'; +import { ownedTech } from '../game/tech-tokens'; +import { initializeAdvancedNativeSkillsSetup } from './fixture-advanced-native-skills'; +import { holdMoritaniStrongholdsTraitor } from './fixture-moritani-strongholds'; +import { nextStrongholdFactionsNativeStep, type StrongholdFactionsNativeStep } from './fixture-stronghold-factions'; + +export type MoritaniSkillsModulesOptions = { + initial?: Game; + rules?: 'basic' | 'advanced'; + tech?: boolean; + strongholds?: boolean; + holder?: 'moritani' | 'guild'; + normalCall?: boolean; +}; +export type MoritaniSkillsModulesFixture = { + initial: Game; afterSetup: Game; afterFirstStorm: Game; + beforeFirstMentat: Game; firstMentatStep: StrongholdFactionsNativeStep; afterFirstMentat: Game; + beforeShipment: Game; shipmentStep: StrongholdFactionsNativeStep; afterShipment: Game; afterMovement: Game; + beforeBattle: Game; game: Game; revealed: Game; pending: Game; + moritani: string; opponent: string; holder: string; target: string; + moritaniLeader: string; opponentLeader: string; + moritaniCards: string[]; opponentCards: string[]; + battleAction: StrongholdFactionsNativeStep; planActions: StrongholdFactionsNativeStep[]; + sourceActions: StrongholdFactionsNativeStep[]; staging: string[]; +}; +export function moritaniSkillsModulesPlayer(game: Game, id: string) { + const player = game.players.find(p => p.id === id); assert.ok(player, `Missing original seat ${id}`); return player; +} +export function moritaniSkillsModulesPolicy(game: Game, actor: string, difficulty: Difficulty = 'Easy') { + const view = viewGame(game, actor); view.players.find(p => p.id === actor)!.bot = difficulty; + return botActions(view); +} +/** A response pass queue takes precedence over every simultaneous native choice. + * A null result is the genuine boundary requiring a human sealed Battle Plan. */ +export function nextMoritaniSkillsModulesStep(game: Game): StrongholdFactionsNativeStep | null { + if (game.response || game.phaseOpening || game.pendingTreacheryDiscard) + return nextStrongholdFactionsNativeStep(game); + const d = game.decision; + if (d?.kind === 'leaderSkillVisibility') return { actor: d.player, action: { type: 'leaderSkillVisibility', event: d.event, hide: false } }; + if (d?.kind === 'moritaniSetup') { + const action = moritaniSkillsModulesPolicy(game, d.player)[0]; assert.ok(action); + return { actor: d.player, action }; + } + if (d?.kind === 'moritaniPlacement') return { actor: d.player, action: { type: 'decision', decline: true } }; + if (d?.kind === 'moritaniAssassinate') return { actor: d.player, action: { type: 'decision', event: d.event, decline: true } }; + if (d?.kind === 'techToken') return { actor: d.player, action: { type: 'decision', token: d.choices[0] } }; + return nextStrongholdFactionsNativeStep(game); +} +function run(game: Game, step: StrongholdFactionsNativeStep, actions: StrongholdFactionsNativeStep[]) { + actions.push(structuredClone(step)); return applyAction(game, step.actor, step.action); +} +export function advanceMoritaniSkillsModules(game: Game, until: (state: Game) => boolean, actions: StrongholdFactionsNativeStep[] = []): Game { + for (let count = 0; count < 2000; count++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished'); + const next = nextMoritaniSkillsModulesStep(game); assert.ok(next, 'A real original sealed Battle Plan is required'); + game = run(game, next, actions); + } + throw Error('Original Moritani skills/modules continuation did not reach its requested boundary'); +} +export const moritaniSkillsModulesClean = (game: Game) => !game.response && !game.decision && !game.phaseOpening; + +/** Native offers and starting wallets; never convert a played save or redeal a + * supplied original setup. Lobby modules are enabled by their real host actions. */ +export function initializeMoritaniSkillsModulesOffers(options: MoritaniSkillsModulesOptions = {}): Game { + const advanced = options.rules ? options.rules === 'advanced' : options.initial?.advanced ?? true; + const tech = options.tech ?? true; + const strongholds = options.strongholds ?? advanced; + let game: Game; + if (options.initial) game = structuredClone(options.initial); + else { + game = createGame('MORITANISKILLSMODULES', newPlayer('moritani', 'Moritani', 'moritani'), advanced, ['ecaz']); + joinGame(game, newPlayer('guild', 'Guild', 'guild')); joinGame(game, newPlayer('emperor', 'Emperor', 'emperor')); + } + assert.equal(game.advanced, advanced); assert.deepEqual(game.expansions, ['ecaz']); + assert.ok(game.players.length >= (tech ? 3 : 2) && game.players.length <= 6); + assert.equal(game.players.filter(p => p.faction === 'moritani').length, 1); + assert.equal(game.players.filter(p => p.faction === 'guild').length, 1); + const allowed: FactionId[] = ['moritani', 'atreides', 'emperor', 'fremen', 'guild', 'beneGesserit']; + assert.ok(game.players.every(p => allowed.includes(p.faction))); + assert.ok(tech || strongholds); assert.ok(!strongholds || advanced); + if (game.status === 'lobby') { + if (!!game.techTokens !== tech) game = applyAction(game, game.host, { type: 'techTokens', enabled: tech }); + if (!!game.strongholdCards !== strongholds) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: strongholds }); + } else { + assert.equal(!!game.techTokens, tech); assert.equal(!!game.strongholdCards, strongholds); + } + game = initializeAdvancedNativeSkillsSetup({ family: 'moritani', skillOwner: 'guild', requestedSkill: 'suk-graduate', + rules: advanced ? 'advanced' : 'basic', initial: game }); + while (game.setupStage === 'prediction') { + const next = nextMoritaniSkillsModulesStep(game); assert.ok(next); + game = applyAction(game, next.actor, next.action); + } + return game; +} +export function completeMoritaniSkillsModulesSetup(state: Game): Game { + let game = state; + const guild = game.players.find(p => p.faction === 'guild')!.id; + for (let count = 0; game.status === 'setup' && count < 200; count++) { + if (game.setupStage === 'leaderSkills') { + const actor = Object.keys(game.leaderSkills!.offers)[0], own = viewGame(game, actor).leaderSkills!; + const offer = game.leaderSkills!.offers[actor]; + const skill = actor === guild ? 'suk-graduate' : offer.cards.find(c => !own.unavailableSkills?.[c]); + const leader = actor === guild ? own.eligibleLeaders.find(l => l.name === 'Master Bewt') : own.eligibleLeaders[0]; + assert.ok(skill && offer.cards.includes(skill) && leader); + game = applyAction(game, actor, { type: 'leaderSkill', event: offer.event, skill, leader: leader.id }); + } else { + const next = nextMoritaniSkillsModulesStep(game); assert.ok(next); + game = applyAction(game, next.actor, next.action); + } + } + assert.equal(game.status, 'playing'); return game; +} +function reserveBoard(game: Game) { + for (const p of game.players) { + p.reserves += Object.values(p.forces).reduce((sum, n) => sum + n, 0); p.forces = {}; + if (p.elites) { p.elites.reserves += Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0); p.elites.forces = {}; } + if (p.advisors) p.advisors = {}; + } +} +function place(game: Game, actor: string, location: string, count: number) { + const p = moritaniSkillsModulesPlayer(game, actor); assert.ok(p.reserves >= count); + p.reserves -= count; p.forces[location] = count; +} +function heldCard(game: Game, actor: string, kind: Card['kind'], staging: string[], excluded: string[] = []) { + const p = moritaniSkillsModulesPlayer(game, actor); + const held = p.hand.find(c => c.kind === kind && !excluded.includes(c.id)); if (held) return held.id; + const index = game.deck.findIndex(c => c.kind === kind && !excluded.includes(c.id)); assert.ok(index >= 0); + assert.ok(p.hand.length < 4); const [card] = game.deck.splice(index, 1); p.hand.push(card); + staging.push(`Conserved native ecaz33 ${card.id}: remaining deck → ${actor}'s hand`); return card.id; +} +export function stageMoritaniSkillsModulesBattle(game: Game, moritani: string, opponent: string, staging: string[] = []) { + assert.equal(game.phase, 5); assert.ok(moritaniSkillsModulesClean(game)); + reserveBoard(game); + const location = `tueks_sietch:${territory('tueks_sietch').sectors[0]}`; + place(game, moritani, location, 6); place(game, opponent, location, 6); + staging.push('Before actual movement completion: relocate six original counters per combatant through own reserves; no currency, storm, phase, turn or card-owner edits.'); + return game; +} +export function revealMoritaniSkillsModulesPlans(game: Game, plans: StrongholdFactionsNativeStep[], actions: StrongholdFactionsNativeStep[] = []): Game { + for (const plan of plans) { + game = run(game, plan, actions); + while (game.response || game.decision?.kind === 'fullPlanOffer') { + const next = nextMoritaniSkillsModulesStep(game); assert.ok(next); game = run(game, next, actions); + } + } + assert.ok(game.battle?.revealed); return game; +} +/** Stop BEFORE original Suk rescue or original assassination; do not erase the + * winner's casualties, card decisions or saved continuation to reach a choice. */ +export function resolveMoritaniSkillsModulesBattle(game: Game, moritani: string, normalCall = false, actions: StrongholdFactionsNativeStep[] = []): Game { + for (let count = 0; count < 160; count++) { + if (game.decision?.kind === 'sukRescue' || game.decision?.kind === 'moritaniAssassinate' || + !game.battle && moritaniSkillsModulesClean(game) && !game.pendingTreacheryDiscard) return game; + const next = nextMoritaniSkillsModulesStep(game); assert.ok(next); + if (next.action.type === 'traitorCall') next.action.call = normalCall && next.actor === moritani; + game = run(game, next, actions); + } + throw Error('Original Moritani skills/modules battle suffix did not settle'); +} +export function createMoritaniSkillsModulesFixture(options: MoritaniSkillsModulesOptions = {}): MoritaniSkillsModulesFixture { + const actions: StrongholdFactionsNativeStep[] = [], staging: string[] = []; + let game = initializeMoritaniSkillsModulesOffers(options); + const initial = structuredClone(game); + game = completeMoritaniSkillsModulesSetup(game); const afterSetup = structuredClone(game); + const moritani = game.players.find(p => p.faction === 'moritani')!.id; + const opponent = game.players.find(p => p.faction === 'guild')!.id; + const holder = options.holder === 'moritani' ? moritani : opponent; + while (game.phase === 0) { + const next = nextMoritaniSkillsModulesStep(game); assert.ok(next); + if (next.action.type === 'stormDial') next.action.amount = 4; + game = run(game, next, actions); + } + const afterFirstStorm = structuredClone(game); + game = advanceMoritaniSkillsModules(game, s => s.phase === 8 && moritaniSkillsModulesClean(s), actions); + reserveBoard(game); + place(game, holder, `tueks_sietch:${territory('tueks_sietch').sectors[0]}`, 1); + place(game, holder === moritani ? opponent : moritani, `carthag:${territory('carthag').sectors[0]}`, 1); + staging.push('Before real first END Mentat: conserved native counters, one controlled stronghold each; card ownership remains entirely engine-derived.'); + let beforeFirstMentat: Game | undefined, firstMentatStep: StrongholdFactionsNativeStep | undefined; + while (game.turn === 1) { + const next = nextMoritaniSkillsModulesStep(game); assert.ok(next); + const before = structuredClone(game); game = run(game, next, actions); + if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = next; } + } + assert.ok(beforeFirstMentat && firstMentatStep); + const afterFirstMentat = structuredClone(game); + game = advanceMoritaniSkillsModules(game, s => s.phase === 5 && moritaniSkillsModulesClean(s), actions); + stageMoritaniSkillsModulesBattle(game, moritani, opponent, staging); + const m = moritaniSkillsModulesPlayer(game, moritani), guild = moritaniSkillsModulesPlayer(game, opponent); + const trained = new Set(game.leaderSkills!.assignments.map(a => a.leader)); + const ownLeader = m.leaders.filter(l => !l.dead && !trained.has(l.id)).sort((a, b) => a.strength - b.strength)[0]; + const enemyLeader = guild.leaders.filter(l => !l.dead && !trained.has(l.id)).sort((a, b) => b.strength - a.strength)[0]; + const target = game.leaderSkills!.assignments.find(a => a.owner === opponent)!.leader; + assert.ok(ownLeader && enemyLeader && enemyLeader.id !== target); + holdMoritaniStrongholdsTraitor(game, moritani, options.normalCall ? enemyLeader.id : target, staging); + const moritaniCards = [heldCard(game, moritani, 'worthless', staging)]; + moritaniCards.push(heldCard(game, moritani, 'worthless', staging, moritaniCards)); + const opponentCards = [heldCard(game, opponent, 'worthless', staging)]; + // Empty one selected battle deposit by returning it to the unoccupied Polar + // Sink. This is explicit conserved board-spice staging, not minted bank money. + const location = `tueks_sietch:${territory('tueks_sietch').sectors[0]}`; + const deposit = game.spice[location] ?? 0; + if (deposit) { delete game.spice[location]; game.spice['polar_sink:0'] = (game.spice['polar_sink:0'] ?? 0) + deposit; } + staging.push(`Conserved board deposit ${deposit}: battle sector → unoccupied Polar Sink; isolate real battle/income wallet legs.`); + // One actual non-Guild shipment triggers printed Heighliners accrual. Keep + // native active/order/shipped fields intact and reach Moritani by actions. + game = advanceMoritaniSkillsModules(game, s => s.phase === 5 && s.active === moritani && moritaniSkillsModulesClean(s), actions); + const destination = TERRITORIES.find(site => site.type === 'sand' && !site.sectors.includes(game.storm) && + !Object.entries(game.spice).some(([key, count]) => count > 0 && key.startsWith(`${site.id}:`)) && + game.players.every(p => !Object.entries(p.forces).some(([key, count]) => count > 0 && key.startsWith(`${site.id}:`)))); + assert.ok(destination, 'The original invoice needs an empty, spice-free desert destination.'); + const beforeShipment = structuredClone(game); + const shipmentStep: StrongholdFactionsNativeStep = { actor: moritani, action: { type: 'ship', + territory: destination.id, sector: destination.sectors[0], amount: 1 } }; + game = run(game, shipmentStep, actions); + game = advanceMoritaniSkillsModules(game, s => s.phase === 5 && moritaniSkillsModulesClean(s) && !s.pendingShipment, actions); + const afterShipment = structuredClone(game); + game = advanceMoritaniSkillsModules(game, s => s.phase === 6 && moritaniSkillsModulesClean(s), actions); + const afterMovement = structuredClone(game), beforeBattle = structuredClone(game); + const actor = game.active!; assert.ok([moritani, opponent].includes(actor)); + const battleAction: StrongholdFactionsNativeStep = { actor, action: { type: 'chooseBattle', territory: 'tueks_sietch', target: actor === moritani ? opponent : moritani } }; + game = run(game, battleAction, actions); + game = advanceMoritaniSkillsModules(game, s => !!s.battle && !s.battle.revealed && !nextMoritaniSkillsModulesStep(s), actions); + const planActions: StrongholdFactionsNativeStep[] = [ + { actor: moritani, action: { type: 'battlePlan', leader: ownLeader.id, dial: 1, ...(game.advanced ? { support: 1 } : {}), weapon: moritaniCards[0], defense: moritaniCards[1] } }, + { actor: opponent, action: { type: 'battlePlan', leader: enemyLeader.id, dial: 3, ...(game.advanced ? { support: 3 } : {}), weapon: opponentCards[0] } }, + ]; + const prepared = structuredClone(game), revealed = revealMoritaniSkillsModulesPlans(game, planActions, actions); + const pending = resolveMoritaniSkillsModulesBattle(structuredClone(revealed), moritani, options.normalCall, actions); + // Three-seat originals get one token per seat; larger originals must not + // fabricate missing ownership merely to offer a post-battle Tech choice. + if (afterFirstStorm.techTokens && afterFirstStorm.players.length === 3) assert.equal(ownedTech(afterFirstStorm.techTokens, opponent).length, 1); + return { initial, afterSetup, afterFirstStorm, beforeFirstMentat, firstMentatStep, afterFirstMentat, + beforeShipment, shipmentStep, afterShipment, afterMovement, beforeBattle, game: prepared, revealed, pending, + moritani, opponent, holder, target, moritaniLeader: ownLeader.id, opponentLeader: enemyLeader.id, + moritaniCards, opponentCards, battleAction, planActions, sourceActions: actions, staging }; +} +export function rejectMoritaniSkillsModulesAction(game: Game, actor: string, action: Action) { + const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before); +} diff --git a/tests/fixture-stronghold-factions.ts b/tests/fixture-stronghold-factions.ts index f0e34734..f5de9c8e 100644 --- a/tests/fixture-stronghold-factions.ts +++ b/tests/fixture-stronghold-factions.ts @@ -120,7 +120,7 @@ export function nextStrongholdFactionsNativeStep(game: Game): StrongholdFactions if (game.status === 'playing' && !game.decision) { if (game.phase === 0 && game.stormPending === null) { const actor = game.stormDialers.find(id => game.stormDials[id] === undefined); - if (actor) return { actor, action: { type: 'stormDial', amount: 0 } }; + if (actor) return { actor, action: { type: 'stormDial', amount: game.turn === 1 ? 0 : 1 } }; } if (game.phase === 3 && game.auction) return { actor: game.auction.active, action: { type: 'passBid' } }; if (game.phase === 5) return { actor: game.active!, action: { type: 'endMovement' } }; diff --git a/tests/moritani-skills-modules-runtime.test.ts b/tests/moritani-skills-modules-runtime.test.ts new file mode 100644 index 00000000..373af242 --- /dev/null +++ b/tests/moritani-skills-modules-runtime.test.ts @@ -0,0 +1,335 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + applyAction, createGame, joinGame, newPlayer, normalizeAutomaticGame, viewGame, + type Action, type Game, +} from '../game/engine'; +import { leaders, treacheryDeck } from '../game/cards'; +import type { FactionId } from '../game/catalog'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards'; +import { validateLeaderSkills } from '../game/leader-skills'; +import { quoteSpiceCollection } from '../game/board-resolution-quote'; +import { STRONGHOLD_CARDS, strongholdControllers } from '../game/stronghold-cards'; +import { ownedTech } from '../game/tech-tokens'; +import { assassinationPhysical } from './moritani-assassinate-fixture'; +import { quoteStrongholdFactionsBattle } from './fixture-stronghold-factions'; +import { holdMoritaniStrongholdsTraitor } from './fixture-moritani-strongholds'; +import { + advanceMoritaniSkillsModules as advance, completeMoritaniSkillsModulesSetup, + createMoritaniSkillsModulesFixture as fixture, initializeMoritaniSkillsModulesOffers, + moritaniSkillsModulesClean as clean, moritaniSkillsModulesPlayer as player, + moritaniSkillsModulesPolicy as policy, nextMoritaniSkillsModulesStep, + rejectMoritaniSkillsModulesAction as reject, resolveMoritaniSkillsModulesBattle, + revealMoritaniSkillsModulesPlans, stageMoritaniSkillsModulesBattle, + type MoritaniSkillsModulesOptions, +} from './fixture-moritani-skills-modules'; + +const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game; +function custody(game: Game) { + validateLeaderSkills(game.leaderSkills!, game.players); + const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand), + ...(game.auction?.cards.slice(game.auction.index) ?? [])].map(c => c.id).sort(); + assert.deepEqual(cards, treacheryDeck(['ecaz']).map(c => c.id).sort()); + assert.equal(cards.length, 33); + const skills = [...game.leaderSkills!.deck, ...Object.values(game.leaderSkills!.offers).flatMap(o => o.cards), + ...game.leaderSkills!.assignments.map(a => a.skill)].sort(); + assert.deepEqual(skills, LEADER_SKILL_CARDS.map(c => c.id).sort()); + for (const p of game.players) { + assert.deepEqual(p.leaders.map(l => l.id).sort(), leaders(p.faction).map(l => l.id).sort()); + assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum, n) => sum + n, 0), 20); + if (p.elites) { + assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0), p.faction === 'emperor' ? 5 : 3); + for (const [location, count] of Object.entries(p.elites.forces)) assert.ok(count <= (p.forces[location] ?? 0)); + } + } +} +function assassinate(game: Game, card: string | null) { + const d = game.decision; assert.equal(d?.kind, 'moritaniAssassinate'); + if (d?.kind !== 'moritaniAssassinate') throw Error('Expected original loss opportunity'); + return applyAction(game, d.player, { type: 'decision', event: d.event, ...(card === null ? { decline: true } : { card }) }); +} +function finishBattle(game: Game) { + return advance(game, s => !s.battle && clean(s) && !s.pendingTreacheryDiscard); +} + +void test('original standalone Moritani retains native offers and authenticated roster across Basic Tech and Advanced module seat boundaries', () => { + const cases: { options: MoritaniSkillsModulesOptions; counts: number[] }[] = [ + { options: { rules: 'basic', tech: true, strongholds: false }, counts: [3, 4, 5, 6] }, + { options: { rules: 'advanced', tech: true, strongholds: false }, counts: [3, 4, 5, 6] }, + { options: { rules: 'advanced', tech: false, strongholds: true }, counts: [2, 3, 4, 5, 6] }, + { options: { rules: 'advanced', tech: true, strongholds: true }, counts: [3, 4, 5, 6] }, + ]; + const factions: FactionId[] = ['moritani', 'guild', 'emperor', 'atreides', 'fremen', 'beneGesserit']; + for (const { options, counts } of cases) for (const count of counts) { + const lobby = createGame(`AUTHMORISKILLS${count}`, newPlayer('authenticated-m', 'Original Moritani', 'moritani'), options.rules === 'advanced', ['ecaz']); + for (let i = 1; i < count; i++) joinGame(lobby, newPlayer(`authenticated-${i}`, `Original ${factions[i]}`, factions[i])); + const before = structuredClone(lobby), offered = initializeMoritaniSkillsModulesOffers({ ...options, initial: lobby }); + assert.deepEqual(lobby, before); + assert.deepEqual(offered.players.map(p => ({ id: p.id, name: p.name, faction: p.faction })), before.players.map(p => ({ id: p.id, name: p.name, faction: p.faction }))); + assert.equal(offered.setupStage, 'leaderSkills'); + assert.deepEqual(offered.players.map(p => p.traitors), before.players.map(() => [])); + const guild = offered.players.find(p => p.faction === 'guild')!.id; + assert.ok(viewGame(offered, guild).leaderSkills!.offer!.cards.includes('suk-graduate')); + for (const p of offered.players) assert.equal(viewGame(offered, p.id).leaderSkills!.offer!.cards.length, 2); + if (offered.strongholdCards) assert.ok(Object.values(offered.strongholdCards.owners).every(owner => owner === null)); + assert.equal(!!offered.moritaniAssassinate, options.rules === 'advanced'); + const saved = initializeMoritaniSkillsModulesOffers({ ...options, initial: reload(offered) }); + assert.deepEqual(saved, offered, 'An original skill setup is continued, not redealt'); + const setup = completeMoritaniSkillsModulesSetup(offered); + assert.equal(setup.players.find(p => p.faction === 'moritani')!.reserves, 14); + const moritani = setup.players.find(p => p.faction === 'moritani')!; + const placement = Object.entries(moritani.forces); + assert.equal(placement.length, 1); + assert.equal(placement[0][1], 6, 'Native setup places its six original counters together.'); + const start = placement[0][0].split(':')[0]; + assert.ok(setup.players.filter(p => p.id !== moritani.id).every(p => + !Object.entries(p.forces).some(([key, count]) => count > 0 && key.startsWith(`${start}:`))), + 'Original setup must leave other fighters and advisors outside the Moritani starting territory.'); + const assigned = setup.leaderSkills!.assignments.find(a => a.owner === guild)!; + assert.equal(assigned.skill, 'suk-graduate'); + assert.equal(player(setup, guild).leaders.find(l => l.id === assigned.leader)!.name, 'Master Bewt'); + custody(setup); + } +}); + +void test('actual first Storm assigns Tech, a real native shipment pays phase-end income, and END Mentat alone claims the original Stronghold cards', () => { + const f = fixture(); + assert.ok(Object.values(f.initial.techTokens!).every(token => token.owner === null)); + assert.ok(Object.values(f.afterFirstStorm.techTokens!).every(token => token.owner !== null)); + for (const p of f.afterFirstStorm.players) assert.equal(ownedTech(f.afterFirstStorm.techTokens, p.id).length, 1); + const shipping = advance(applyAction(reload(f.beforeShipment), f.shipmentStep.actor, f.shipmentStep.action), + s => s.phase === 5 && clean(s) && !s.pendingShipment); + assert.deepEqual(shipping.techTokens, f.afterShipment.techTokens); + const token = f.afterShipment.techTokens!.heighliners; + assert.equal(token.triggeredTurn, 2); assert.equal(token.spice, 1); + assert.equal(player(shipping, f.moritani).reserves, player(f.beforeShipment, f.moritani).reserves - 1); + assert.equal(player(shipping, f.moritani).forces[`${String(f.shipmentStep.action.territory)}:${String(f.shipmentStep.action.sector)}`], 1); + assert.equal(f.afterMovement.techTokens!.heighliners.spice, 0); + for (const p of shipping.players) assert.equal(player(f.afterMovement, p.id).spice, p.spice + (p.id === token.owner ? 1 : 0), 'Exactly the printed token holder collects at the real phase boundary'); + assert.equal(f.initial.strongholdCards!.claimedTurn, 0); + assert.ok(Object.values(f.initial.strongholdCards!.owners).every(owner => owner === null)); + const claimed = applyAction(reload(f.beforeFirstMentat), f.firstMentatStep.actor, f.firstMentatStep.action); + assert.equal(claimed.turn, 2); + assert.deepEqual(claimed.strongholdCards!.owners, strongholdControllers(f.beforeFirstMentat.players, false)); + assert.deepEqual(claimed.strongholdCards, f.afterFirstMentat.strongholdCards); + assert.equal(claimed.strongholdCards!.owners.tueks_sietch, f.opponent); + assert.deepEqual(Object.keys(claimed.strongholdCards!.owners), STRONGHOLD_CARDS.map(c => c.id)); + for (const g of [f.afterSetup, f.afterFirstStorm, f.afterFirstMentat, f.afterShipment, f.pending]) custody(g); +}); + +void test('the normal face-up unused Guild Suk rescues one first; original held Master Bewt assassination returns Suk and printed3 once without replaying winner income or casualties', () => { + const f = fixture(), pending = f.pending; + assert.equal(pending.lastBattleContext?.result, 'normal'); assert.equal(pending.lastBattleContext?.winner, f.opponent); + assert.equal(pending.decision?.kind, 'moritaniAssassinate'); + assert.equal(pending.lastBattleContext?.sukRescue?.completed, true); + const target = player(pending, f.opponent).leaders.find(l => l.id === f.target)!; + assert.equal(target.name, 'Master Bewt'); assert.equal(target.strength, 3); assert.equal(target.dead, false); + assert.notEqual(target.id, f.opponentLeader); + assert.equal(player(pending, f.opponent).tanks, player(f.revealed, f.opponent).tanks + 2, 'Guild dial3 loses only two counters to Tanks after normal rescue1'); + assert.equal(player(pending, f.opponent).reserves, player(f.revealed, f.opponent).reserves + 1); + assert.equal(Object.values(player(pending, f.opponent).forces).reduce((sum, n) => sum + n, 0), 3); + assert.equal(player(pending, f.moritani).tanks, player(f.revealed, f.moritani).tanks + 6); + assert.deepEqual(quoteStrongholdFactionsBattle(f.revealed).strongholdIncome, [{ player: f.opponent, amount: 2 }]); + const moriBalance = player(f.revealed, f.moritani).spice - 1; + const winnerBalance = player(f.revealed, f.opponent).spice - 3 + 2; + assert.equal(player(pending, f.moritani).spice, moriBalance); + assert.equal(player(pending, f.opponent).spice, winnerBalance); + const originalForces = pending.players.map(p => ({ id: p.id, forces: p.forces, reserves: p.reserves, tanks: p.tanks, elites: p.elites })); + const retained = structuredClone(pending.leaderSkills!.assignments.filter(a => a.leader !== f.target)); + const reserve = [...pending.traitorReserve!], traitors = [...player(pending, f.moritani).traitors]; + const inventory = assassinationPhysical(pending); + const d = pending.decision; + if (d?.kind !== 'moritaniAssassinate') throw Error('Expected original loss choice'); + const action: Action = { type: 'decision', event: d.event, card: f.target }; + const privateMenu = viewGame(pending, f.moritani).moritaniAssassinate!.pending!.cards; + assert.equal(privateMenu.find(c => c.card === f.target)!.bounty, 3); + assert.equal(privateMenu.some(c => c.card === f.opponentLeader), false); + reject(pending, f.opponent, action); reject(pending, f.moritani, { ...action, event: `${d.event}:stale` }); + reject(pending, f.moritani, { ...action, card: f.opponentLeader }); + let game = applyAction(reload(pending), f.moritani, action); + assert.equal(player(game, f.opponent).leaders.find(l => l.id === f.target)!.dead, true); + assert.equal(player(game, f.opponent).leaders.find(l => l.id === f.target)!.deaths, target.deaths + 1); + assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.bounty, 3); + assert.deepEqual(game.leaderSkills!.assignments, retained); + assert.equal(game.leaderSkills!.deck.filter(c => c === 'suk-graduate').length, 1); + assert.equal(player(game, f.moritani).spice, moriBalance + 3); assert.equal(player(game, f.opponent).spice, winnerBalance); + assert.deepEqual(game.players.map(p => ({ id: p.id, forces: p.forces, reserves: p.reserves, tanks: p.tanks, elites: p.elites })), originalForces); + assert.deepEqual(game.strongholdCards, pending.strongholdCards); + assert.deepEqual(game.techTokens, pending.techTokens, 'Assassination does not replay or undo original phase income'); + assert.equal(game.decision?.kind, 'battleCards'); assert.equal(game.decision?.player, f.opponent); + reject(game, f.moritani, action); + game = applyAction(reload(game), f.opponent, { type: 'decision', discard: f.opponentCards }); + game = finishBattle(game); + for (const card of [...f.moritaniCards, ...f.opponentCards]) assert.equal(game.discard.filter(c => c.id === card).length, 1); + const lostTech = ownedTech(pending.techTokens, f.moritani)[0]; assert.ok(lostTech); + assert.equal(game.techTokens![lostTech].owner, f.opponent, 'Winner captures one real defeated-faction token only after original winner cleanup'); + for (const token of ownedTech(pending.techTokens, f.opponent)) assert.equal(game.techTokens![token].owner, f.opponent, 'Original winning Tech custody is retained alongside the captured token'); + const afterBattleForces = game.players.map(p => ({ id: p.id, forces: p.forces, reserves: p.reserves, tanks: p.tanks })); + assert.deepEqual(afterBattleForces, pending.players.map(p => ({ id: p.id, forces: p.forces, reserves: p.reserves, tanks: p.tanks }))); + game = advance(reload(game), s => s.phase === 8); + const receipt = game.moritaniAssassinate!.opportunities.at(-1)!; + assert.equal(receipt.stage, 'replaced'); assert.equal(receipt.replacement, reserve[0]); + assert.deepEqual(game.traitorReserve, reserve.slice(1)); + assert.deepEqual(player(game, f.moritani).traitors, [...traitors.filter(c => c !== f.target), reserve[0]]); + const collected = quoteSpiceCollection(pending).receipts.find(r => r.player === f.opponent)!; + assert.equal(player(game, f.moritani).spice, moriBalance + 3); + assert.equal(player(game, f.opponent).spice, winnerBalance + collected.strongholds + collected.collected, + 'Ordinary Collection remains separate from original battle income and assassination bounty.'); + assert.equal('replacement' in viewGame(game, f.opponent).moritaniAssassinate!.history.at(-1)!, false); + assert.equal('traitors' in viewGame(game, f.opponent).players.find(p => p.id === f.moritani)!, false); + assert.equal(game.leaderSkills!.deck.filter(c => c === 'suk-graduate').length, 1); + assert.deepEqual(assassinationPhysical(game), inventory); + assert.deepEqual(normalizeAutomaticGame(reload(game)), game); + reject(game, f.moritani, action); custody(game); + game = advance(game, s => s.turn === 3 && s.phase === 4 && clean(s)); + assert.equal(game.traitorReserve!.length, reserve.length - 1, 'The next real END Mentat does not replace a second time'); + reject(game, f.opponent, { type: 'reviveLeader', leader: f.target }); + assert.equal(player(game, f.opponent).leaders.filter(l => l.dead).length, 1); + assert.equal(game.leaderSkills!.offers[f.opponent], undefined, 'One original death must not bypass the native all-five-disc revival prerequisite or grant training'); + custody(game); +}); + +void test('losing Moritani retains printed Tuek Worthless income and one real support debit even after Guild rescue, assassination, card cleanup and Tech transfer', () => { + const f = fixture({ holder: 'moritani' }); + assert.deepEqual(quoteStrongholdFactionsBattle(f.revealed).strongholdIncome, [{ player: f.moritani, amount: 4 }]); + const own = player(f.revealed, f.moritani).spice - 1 + 4; + const enemy = player(f.revealed, f.opponent).spice - 3; + assert.equal(player(f.pending, f.moritani).spice, own); assert.equal(player(f.pending, f.opponent).spice, enemy); + assert.equal(f.pending.lastBattleContext?.sukRescue?.completed, true); + let game = assassinate(reload(f.pending), f.target); + game = finishBattle(game); + game = advance(game, s => s.phase === 8); + const collected = quoteSpiceCollection(f.pending).receipts.find(r => r.player === f.opponent)!; + assert.equal(player(game, f.moritani).spice, own + 3); + assert.equal(player(game, f.opponent).spice, enemy + collected.strongholds + collected.collected); + assert.equal(player(game, f.moritani).tanks, 6); assert.equal(player(game, f.opponent).tanks, 2); + assert.equal(player(game, f.moritani).leaders.find(l => l.id === f.moritaniLeader)!.dead, false); + for (const card of f.moritaniCards) assert.equal(game.discard.filter(c => c.id === card).length, 1); + assert.equal(game.strongholdCards!.owners.tueks_sietch, f.moritani); + assert.equal(strongholdControllers(game.players, false).tueks_sietch, f.opponent); + custody(game); +}); + +void test('selected winning disc is not an assassination target, and declining a real empty native menu neither kills Suk nor schedules replacement', () => { + const f = fixture(); + let game = reload(f.game); + holdMoritaniStrongholdsTraitor(game, f.moritani, f.opponentLeader); + game = revealMoritaniSkillsModulesPlans(game, f.planActions); + game = resolveMoritaniSkillsModulesBattle(game, f.moritani); + assert.equal(game.lastBattleContext?.winner, f.opponent); + assert.deepEqual(viewGame(game, f.moritani).moritaniAssassinate!.pending!.cards, []); + const d = game.decision; + if (d?.kind !== 'moritaniAssassinate') throw Error('Expected original empty loss choice'); + reject(game, f.moritani, { type: 'decision', event: d.event, card: f.opponentLeader }); + const skills = structuredClone(game.leaderSkills), reserve = [...game.traitorReserve!]; + const balance = player(game, f.moritani).spice; + game = finishBattle(assassinate(game, null)); + game = advance(game, s => s.phase === 8); + assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.stage, 'declined'); + assert.deepEqual(game.leaderSkills, skills); assert.deepEqual(game.traitorReserve, reserve); + assert.equal(player(game, f.moritani).spice, balance); + assert.equal(player(game, f.opponent).leaders.find(l => l.id === f.target)!.dead, false); + assert.equal(player(game, f.opponent).leaders.find(l => l.id === f.opponentLeader)!.dead, false); + custody(game); +}); + +void test('a real normal Traitor reveal keeps the original entire-game forfeiture across a native turn, not merely the first modules battle', () => { + const f = fixture({ normalCall: true }); + assert.equal(f.pending.lastBattleContext?.result, 'traitor'); + assert.equal(f.pending.lastBattleContext?.winner, f.moritani); + assert.equal(f.pending.moritaniAssassinate!.normalTraitorCall, true); + assert.equal(f.pending.moritaniAssassinateCallEvents!.length, 1); + assert.deepEqual(f.pending.moritaniAssassinate!.opportunities, []); + assert.equal(player(f.pending, f.opponent).leaders.find(l => l.id === f.opponentLeader)!.dead, true); + let game = advance(f.pending, s => s.turn === 3 && s.phase === 5 && clean(s)); + stageMoritaniSkillsModulesBattle(game, f.moritani, f.opponent); + holdMoritaniStrongholdsTraitor(game, f.moritani, f.target); + const trained = new Set(game.leaderSkills!.assignments.map(a => a.leader)); + const winner = player(game, f.opponent).leaders.filter(l => !l.dead && !trained.has(l.id)).sort((a, b) => b.strength - a.strength)[0]; + const loser = player(game, f.moritani).leaders.filter(l => !l.dead && !trained.has(l.id)).sort((a, b) => a.strength - b.strength)[0]; + assert.ok(winner && loser); + game = advance(game, s => s.phase === 6 && clean(s)); + const actor = game.active!; + game = applyAction(game, actor, { type: 'chooseBattle', territory: 'tueks_sietch', target: actor === f.moritani ? f.opponent : f.moritani }); + game = advance(game, s => !!s.battle && !s.battle.revealed && !nextMoritaniSkillsModulesStep(s)); + game = revealMoritaniSkillsModulesPlans(game, [ + { actor: f.moritani, action: { type: 'battlePlan', leader: loser.id, dial: 0, support: 0 } }, + { actor: f.opponent, action: { type: 'battlePlan', leader: winner.id, dial: 3, support: 3 } }, + ]); + game = finishBattle(resolveMoritaniSkillsModulesBattle(game, f.moritani)); + assert.equal(game.lastBattleContext?.result, 'normal'); assert.equal(game.lastBattleContext?.winner, f.opponent); + assert.equal(game.moritaniAssassinate!.normalTraitorCall, true); + assert.equal(game.moritaniAssassinateCallEvents!.length, 1); + assert.deepEqual(game.moritaniAssassinate!.opportunities, []); + assert.equal(player(game, f.opponent).leaders.find(l => l.id === f.target)!.dead, false); + custody(game); +}); + +void test('all four minimal legal policies reach real reveal or empty-menu decline outcomes without changing native skill, income or casualty custody twice', () => { + const f = fixture(); + let empty = reload(f.game); + holdMoritaniStrongholdsTraitor(empty, f.moritani, f.opponentLeader); + empty = revealMoritaniSkillsModulesPlans(empty, f.planActions); + empty = resolveMoritaniSkillsModulesBattle(empty, f.moritani); + for (const difficulty of DIFFICULTIES) { + const action = policy(reload(f.pending), f.moritani, difficulty)[0]; assert.ok(action, difficulty); + let game = applyAction(reload(f.pending), f.moritani, action); + assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.stage, 'revealed'); + assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.card, f.target); + assert.equal(player(game, f.opponent).leaders.find(l => l.id === f.target)!.dead, true); + assert.equal(player(game, f.moritani).spice, player(f.pending, f.moritani).spice + 3); + assert.equal(player(game, f.opponent).spice, player(f.pending, f.opponent).spice); + assert.equal(game.leaderSkills!.deck.filter(c => c === 'suk-graduate').length, 1); + game = finishBattle(game); + assert.equal(player(game, f.opponent).tanks, 2); assert.equal(player(game, f.moritani).tanks, 6); + custody(game); + const decline = policy(reload(empty), f.moritani, difficulty)[0]; assert.ok(decline, difficulty); + const declined = applyAction(reload(empty), f.moritani, decline); + assert.equal(declined.moritaniAssassinate!.opportunities.at(-1)!.stage, 'declined'); + assert.equal(player(declined, f.opponent).leaders.find(l => l.id === f.target)!.dead, false); + assert.deepEqual(declined.leaderSkills, empty.leaderSkills); + assert.equal(player(declined, f.moritani).spice, player(empty, f.moritani).spice); + custody(finishBattle(declined)); + } +}); + +void test('Basic Tech keeps original normal Suk and token capture without Advanced assassination, while each single Advanced module preserves the real loss aftermath', () => { + const basic = fixture({ rules: 'basic', strongholds: false }); + assert.equal(basic.pending.lastBattleContext?.result, 'normal'); + assert.equal(basic.pending.lastBattleContext?.winner, basic.opponent); + assert.equal(basic.pending.moritaniAssassinate, undefined); + assert.ok(!basic.pending.strongholdCards); + assert.equal(basic.pending.lastBattleContext?.sukRescue?.completed, true); + assert.equal(player(basic.pending, basic.opponent).tanks, 2); + assert.equal(player(basic.pending, basic.moritani).tanks, 6); + assert.equal(player(basic.pending, basic.opponent).spice, player(basic.revealed, basic.opponent).spice, 'Basic dials do not acquire an Advanced support debit'); + assert.equal(player(basic.pending, basic.moritani).spice, player(basic.revealed, basic.moritani).spice); + const lost = ownedTech(basic.revealed.techTokens, basic.moritani)[0]; assert.ok(lost); + assert.equal(basic.pending.techTokens![lost].owner, basic.opponent); + assert.equal(basic.pending.leaderSkills!.assignments.some(a => a.leader === basic.target && a.skill === 'suk-graduate'), true); + custody(basic.pending); + for (const tech of [false, true]) { + const f = fixture({ rules: 'advanced', tech, strongholds: !tech }); + assert.equal(f.pending.lastBattleContext?.winner, f.opponent); + assert.equal(f.pending.decision?.kind, 'moritaniAssassinate'); + assert.equal(f.pending.lastBattleContext?.sukRescue?.completed, true); + assert.equal(player(f.pending, f.opponent).tanks, 2); + const balance = player(f.pending, f.moritani).spice; + let game = finishBattle(assassinate(reload(f.pending), f.target)); + game = advance(game, s => s.phase === 8); + assert.equal(player(game, f.moritani).spice, balance + 3); + assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.stage, 'replaced'); + assert.equal(game.leaderSkills!.deck.filter(c => c === 'suk-graduate').length, 1); + if (tech) { + const captured = ownedTech(f.pending.techTokens, f.moritani)[0]; assert.ok(captured); + assert.equal(game.techTokens![captured].owner, f.opponent); + assert.ok(!game.strongholdCards); + } else { + assert.ok(!game.techTokens); + assert.equal(game.strongholdCards!.owners.tueks_sietch, f.opponent); + assert.equal(player(f.pending, f.opponent).spice, player(f.revealed, f.opponent).spice - 3 + 2); + } + custody(game); + } +}); diff --git a/tools/faction-games.ts b/tools/faction-games.ts index 58203289..bc4826c2 100644 --- a/tools/faction-games.ts +++ b/tools/faction-games.ts @@ -361,6 +361,9 @@ const SKILLS_TECH_SCENARIOS: readonly Scenario[] = [ ...[...IX_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS, ...RICHESE_SKILLS_SCENARIOS] .filter(scenario => scenario.roster.length >= 3) .map((scenario, index): Scenario => ({ ...scenario, ordinal: 316 + index, profile: 'skills-tech' })), + ...[...MORITANI_SKILLS_SCENARIOS, ...ECAZ_SKILLS_SCENARIOS] + .filter(scenario => scenario.roster.length >= 3) + .map((scenario, index): Scenario => ({ ...scenario, ordinal: 600 + index, profile: 'skills-tech' })), ]; const SKILLS_STRONGHOLD_SCENARIOS: readonly Scenario[] = (['skills-stronghold', 'skills-stronghold-tech'] as const).flatMap((profile, band) => [ @@ -369,10 +372,15 @@ const SKILLS_STRONGHOLD_SCENARIOS: readonly Scenario[] = })), ...[...IX_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS, ...RICHESE_SKILLS_SCENARIOS] .filter(scenario => scenario.rules === 'advanced'), + ...[...MORITANI_SKILLS_SCENARIOS, ...ECAZ_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); +const assassinationScenario = (scenario: Scenario) => scenario.rules === 'advanced' && + scenario.roster.includes('moritani') && + (scenario.profile === 'moritani-assassinate' || scenario.profile === 'stronghold-factions' || skillProfile(scenario.profile)); type TraceEntry = { attempt: number; @@ -418,8 +426,9 @@ function usage() { '\nThe four earlier native skill profiles retain their Basic samples. Advanced Ix-skills/choam-skills/tleilaxu-skills use documented native plus classic families; Advanced moritani-skills composes its original non-Harkonnen classic assassination roster. Richese-skills adds Basic/Advanced Richese or paired CHOAM tables, Advanced Richese/Tleilaxu/CHOAM and optional Ix-deck tables without native Ixians. All retain fourteen skills and original cache/native inventories. Native Ixian Technology on Richese lots, own mixed No-Field, other roster/modules and public starts retain their separate guards.' + '\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.' + + '\nSkills-tech uses three-through-six unique seats in Basic/Advanced: classic/base33, supported E1/E2 native skill rosters with original ix/choam decks/cache, or standalone Ecaz/Moritani ecaz33 rosters. 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, mixed E3 families, public starts and played-game conversion stay separate.' + + '\nSkills-stronghold uses fresh Advanced classic or supported 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; other modules, mixed E3 families, public starts and played-game conversion stay separate.' + + '\nAll three combined skill/module profiles additionally admit standalone native Ecaz OR Moritani with classic opponents and the exact ecaz33 deck. Original five-disc Ecaz/Duke assignment and Advanced Harkonnen exclusion remain; Advanced Moritani retains its original non-Harkonnen skill-first assassination and normal-traitor forfeiture. E3 pairs, mixed-family rosters, allied Occupy skills and other overlays stay guarded.' ); } @@ -451,7 +460,7 @@ function positive(value: string | undefined) { function scenarioName(scenario: Scenario) { if ((scenario.profile === 'skills-tech' || strongholdSkillProfile(scenario.profile)) && scenario.expansions.length) { - const native = scenario.roster.filter(faction => ['ixians', 'tleilaxu', 'choam', 'richese'].includes(faction)); + const native = scenario.roster.filter(faction => ['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani'].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}`; } @@ -607,11 +616,9 @@ function resumedGame(path: string) { (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 && + assassinationScenario(candidate) === !!game.moritaniAssassinatePreview && (candidate.profile === 'ecaz-occupy') === !!game.ecazOccupyPreview && - (candidate.profile === 'moritani-assassinate' || (candidate.profile === 'moritani-skills' && candidate.rules === 'advanced') || - (candidate.profile === 'stronghold-factions' && candidate.roster.includes('moritani'))) === !!game.moritaniAssassinate && + assassinationScenario(candidate) === !!game.moritaniAssassinate && candidate.rules === (game.advanced ? 'advanced' : 'basic') && JSON.stringify(candidate.expansions) === JSON.stringify(game.expansions) && diff --git a/tools/start-prototype.ts b/tools/start-prototype.ts index 2023a926..27e850d6 100644 --- a/tools/start-prototype.ts +++ b/tools/start-prototype.ts @@ -26,8 +26,9 @@ async function main() { '\nEcaz-occupy (--profile ecaz-occupy) opts only a fresh ready 2–6-seat lobby with native Ecaz and classic/Ixian/Tleilaxu/CHOAM/Richese opponents (optional native Moritani). Advanced uses distinct selected ecaz/ix/choam decks; Basic uses the exact ecaz deck with classic/optional Moritani opponents. Ordinary33/47/35 cards retain real original offers/HMS/Auditor/Face Dancers and Richese cache. Explicit --ecaz-treachery independently adds the three physical variant cards, making36/50/38 (Advanced only). Basic combined battles require an even Ecaz force count; the preserved publisher odd-force casualty-rounding conflict rejects before any lead choice. Native Richese ordinary and marker-only pools compose with selected lead plans; original own mixed No-Field dialing, whole-plan disclosure and optional overlays remain guarded.' + '\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.', + '\nLeader-skills and banker-income preserve canonical unused Tech Tokens selected in fresh three-through-six-seat classic or supported 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. Mixed E3 families, 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 native two-through-six-seat lobbies. Optional Tech Tokens requires three-through-six seats. All14 original skills, decks/cache and actual end-Mentat Stronghold assignment remain. Basic Strongholds, mixed E3 families, other overlays, public activation and save conversion stay excluded.', + '\nThese skill/module entries additionally preserve standalone Ecaz OR Moritani ecaz33/all14 profiles. Tech is Basic/Advanced three-through-six seats; Strongholds is Advanced two-through-six. Native Ecaz five-disc/Duke assignment and Advanced Harkonnen exclusion, plus original Advanced Moritani skill-first assassination/normal-call forfeiture, remain. E3 pairs, mixed families, allied Occupy skills and other overlays are not opened.', ); return; }