diff --git a/components/duke-vidal.tsx b/components/duke-vidal.tsx index 9a339531..43e5a517 100644 --- a/components/duke-vidal.tsx +++ b/components/duke-vidal.tsx @@ -221,13 +221,15 @@ export function DukeVidal({ game }: { game: GameView }) { ))}

Current support covers Moritani acquisition, direct Ecaz - Ambassador acquisition for Ecaz, battle use and death. + Ambassador acquisition or consensual alliance, battle use + and death, and Ecaz-only paid or Ghola revival to living + set-aside custody. Native ordinary-disc skills may coexist + with the separate Duke without assigning him a skill. Unused Ecaz custody survives the turn; unused Moritani - custody expires. Ecaz alliance and loan alternatives, - reacquisition while already controlled, revival, and - captured or ghola custody remain unfinished. Full Ecaz and - Moritani games are unavailable. Inspection does not select - a battle leader. + custody expires. Duke loans, reacquisition while already + controlled, shared skill assignment and exceptional capture + remain unfinished. Full Ecaz and Moritani games are + unavailable. Inspection does not select a battle leader.

diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 35823662..0bd38120 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -16,6 +16,37 @@ open mode gates. ## Current checkpoint and work +**Native Ecaz ordinary-disc Skills — bounded Development Verified:** +fresh Basic/Advanced Ecaz with classic opponents retains the exact `ecaz` deck, +all fourteen skills, five native assignment discs and original Loyalty/setup. +Ordinary role scores, physical Suk rescue, trainer death/own revival and +Ambassador handlers compose. Separately acquired Duke6 gets only normal +Warmaster1, not trained-disc3; release/death preserves the actual native trainer. +Existing-skilled Ecaz paid5 Duke revival retains the earlier living set-aside +result. Shared-Duke assignment, combined Occupy skills, Advanced Harkonnen and +other roster/module boundaries remain explicit. +Affected67/67, types/lint/build and twenty controlled programs pass. Ten genuine +Basic/Advanced two-through-six-seat games complete4,264 accepted actions/no +rejection,110 JSON continuations and59 battles, including50 Ambassador choices +and one Suk rescue. Original CLI **24HGMJCD v4** retains native setup. +Human390px **LQWE8TZM v18** keeps admitted seats, then uses a conserved +pre-placement board. Actual Ecaz token costs1, Emperor reserve arrival costs1, +and the original owner acquires Duke. A second conserved board/card position +retains that actual acquisition. Real face-up Sanya Ecaz/Warmaster, Duke6, +Baliset and dial0 beat Captain Aramsham5/dial2/support2 by the original7–7 +aggressor tie. Native cleanup keeps Baliset and releases living Duke; refresh +retains Collection/wallet11/eight real Wind Pass counters/Tanks0 and unchanged +training. Opponent's owned view retains wallet7 after the actual two-spice +support. The enlarged Duke component is readable at390px. +See [native scope](LEADER_SKILLS_RUNTIME.md#native-ecaz-ordinary-disc-composition--4-october-2026). +No natural-history claim for controlled positions, full assurance, calibration, +public activation or deployed acceptance. +Final native/guide19/19, types/lint/build and hydrated390px guide pass +(351px topic). The updated Duke inspector now states the implemented +revival/native-skill scope; actual visible proof and QA worker reload retain +human v18/Collection/wallet11/eight counters/living set-aside disc. +Owned temporary drivers/tabs are removed; original games and store remain. + **Native Richese Skills and contribution recipients — bounded Development Verified:** Basic/Advanced fresh native Richese retains its required CHOAM deck, separate ten-card cache, original setup and all fourteen skills. Canonical green diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index d987e888..30339f01 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,65 @@ # Dune implementation status +## 4 October 2026 — native Ecaz ordinary-disc skills and temporary Duke composition + +Two bounded owners supplied native setup/ordinary rescue/revival/Ambassador +cases and temporary Duke acquisition/battle/death/revival cases. One integration +owner added the exact Ecaz/classic Basic/Advanced native profile, shared family +fixture, stable CLI samples, source guidance and real human continuation. +Five native discs receive original skills; the separately controlled Duke is +not inserted into native rosters or traitor/assignment inventory. Basic Harkonnen +remains; Advanced Harkonnen retains its original Duke/capture boundary. + +Existing handlers compose without an alternate rule engine: original +starting-card/public-skill/Loyalty/traitor/six-force setup, normal versus selected +role strength, physically capped native Suk rescue, trained-disc death and +optional paid or physical Ghola own-normal-disc skill replacement. Original +Ambassador payment/entry/bank grant retains training and the entrant's suffix. +Temporary Duke6 with another living face-up native Warmaster gets normal1 only; +surviving release or poison death leaves the native trainer/card untouched. +Actual bounty remains6; eligible Ecaz paid5 revival keeps the same living +set-aside Duke under the earlier user decision. Existing-skilled revival does +not establish shared-Duke assignment. + +First focused run passes6/9. Two new tests mistakenly pinned dead Duke's +incidental `usedAt` metadata; those assertions are removed, not re-pinned. +The Ambassador case omitted the original event; it now submits the actual +pending receipt. Final new cases9/9; affected67/67, types/lint/build pass. +Twenty actual controlled programs cover Basic/Advanced Duke normal/hidden/ +stronger-opponent/death, manual/four-policy native Suk and actual deferred +Banker payment. Ten genuine two-through-six-seat Basic/Advanced CLI games +finish4,264 accepted actions/no rejection,110 JSON continuations,59 battles, +50 Ambassador decisions and one Suk rescue. New stable ordinals283–292 retain +all earlier samples. No strategic calibration or assurance matrix. + +Original CLI **24HGMJCD v4** preserves native setup and seats. Human390px +**LQWE8TZM v18** starts at a conserved pre-Ambassador board after original +native setup. Real placement pays1, Emperor's original reserve arrival pays1, +and the owner-only Acquire Duke control receives the original living disc. +The subsequent conserved Wind Pass/Baliset position retains that actual +acquisition, wallets, seat IDs and physical inventory. Real normal face-up +Sanya Ecaz/Warmaster, Duke6, Baliset and dial0 face Captain Aramsham5 with two +fully paid forces: actual7–7 aggressor tie. An initially over-supported +one-force UI attempt is rejected at unchanged v14; correcting the native wheel +to2/support2 seals normally. Both actual no-traitor choices, normal winning +cleanup and refresh reach Collection/v18/wallet11/eight Wind Pass counters/ +Tanks0 and living set-aside Duke. Native trainer/card remain face up; the +opponent's owned wallet is7. Enlarged canonical Duke inspection is readable. +Its stale unfinished-revival notice is updated to the actual bounded scope. + +Shared-Duke assignment, combined Occupy skills, optional modules and other +existing rule questions remain explicit. No natural-history assertion for +controlled positions, reset, public activation, complete modes or deployment; +live **ab5c782** remains last observed. + +Final native/guide19/19, types/lint/build and visible390px topic (351px width) +pass after the affected Duke inspector notice and canonical source updates. +The actual reloaded inspector describes paid/Ghola set-aside revival and +native ordinary-disc skills without claiming shared assignment or loans. +Original human v18/Collection/wallet11/eight counters/living set-aside Duke +remains after the final isolated worker reload; CLI v4/setup is retained. +Owned temporary entry/rule/staging drivers and managed tabs are removed. + ## 4 October 2026 — native Richese skills and Advanced contribution recipients Two bounded owners supplied canonical Richese green role substitution and diff --git a/docs/LEADER_SKILLS_RUNTIME.md b/docs/LEADER_SKILLS_RUNTIME.md index 0eb67076..631d4702 100644 --- a/docs/LEADER_SKILLS_RUNTIME.md +++ b/docs/LEADER_SKILLS_RUNTIME.md @@ -28,6 +28,46 @@ activation still awaits the uniform-response UX decision; empty hands and Advanced native/combined-module skills remain guarded. See [native preview scope](MENTAT_QUESTION.md#native-skill-integration--2-october-2026). +## Native Ecaz ordinary-disc composition — 4 October 2026 + +Fresh local `leader-skills` now admits Basic/Advanced native Ecaz with classic +opponents, the exact `ecaz` family and all fourteen physical skills. Basic +Harkonnen remains permitted; Advanced Harkonnen keeps the original shared-Duke +capture boundary. Public starts, other factions/modules and combined Occupy +skill battles are not opened. Skill assignment uses the five native discs, +never the separately controlled Duke. Source-ordered starting cards, public +skills, Loyalty/Traitors and original six-force placement remain genuine. + +Ordinary native role scores, physical Suk rescue and trained-disc death compose +with original handlers. Eligible own normal-disc revival through paid first +cohort or a physical Ghola retains optional decline/draw/assignment. Native +Ambassador placement, bank grants and original entrant continuation preserve +training. Explicit Mentat and separate Banker opt-ins retain current questions; +an Ambassador bank grant is not another player's bank payment. + +Real Ecaz Ambassador acquisition gives one temporary Duke6. A separate living +face-up native Warmaster adds only its normal1 with a physical Worthless card, +never trained-disc3. Surviving release and actual poison death preserve native +trainer/card; bounty is printed6. Ecaz's original paid5 revival returns the +same living set-aside disc under the user's earlier ruling. These cases retain +an existing native skill and do not settle shared-Duke skill assignment. + +Focused9/9 and twenty actual controlled programs pass: Basic/Advanced Duke +normal/hidden/stronger-opponent/death, manual/four-policy native Suk, and separate +actual native Banker payment. Ten genuine Basic/Advanced two-through-six-seat +games complete4,264 accepted actions/no rejection,110 JSON continuations and +59 battles, including50 Ambassador decisions and one Suk rescue. Natural and +explicitly controlled evidence are distinct; full combinations/assurance, +strategic AI, public activation and deployment remain unfinished. + +Affected67/67, types/lint/build pass. Original CLI **24HGMJCD v4** retains setup. +Human390px **LQWE8TZM v18** uses original token placement/paid entrant/acquisition, +then a conserved controlled Wind Pass/Baliset position retaining the acquired +disc. Actual Sanya Ecaz/Warmaster normal1 plus Duke6/dial0 ties Captain5/dial2/ +support2 at7–7 and wins as aggressor. Normal cleanup/refresh retains native +training, Collection/wallet11/eight counters/Tanks0 and living set-aside Duke; +opponent's owned wallet is7. Original enlarged Duke inspection is readable. + ## Native Richese composition — 4 October 2026 Fresh local `leader-skills` now composes original native Richese setup and all @@ -90,7 +130,7 @@ Winner Suk rescue and Rihani choice finish before post-loss assassination. A saved unspent receipt advances only after those skills, preserving the actual winner, original excluded battle disc, printed bounty, card return and one private native Mentat replacement without repeating casualties. -Explicit Mentat/Banker opt-ins remain separate; Ecaz/Duke assignment, further +Explicit Mentat/Banker opt-ins remain separate; shared-Duke assignment, further native rosters, public activation and unrelated optional modules stay guarded. ## Advanced Tleilaxu foreign-ghola composition — 4 October 2026 diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index aec68ecb..776c4006 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -142,7 +142,7 @@ See [the cutover and Advanced sequence](MORITANI_ASSASSINATE_LEADERS.md#authorit | --- | --- | --- | | Harkonnen special-Karama forced return | Return automatically only when the combined hand equals the count owed. Keep actual drawn-card observations owner-private after settlement; legacy forced saves describe held-card history without inventing draw provenance. Existing provisional auction-payment recovery and mode gates remain. | [Contract and limits](HARKONNEN_EXCHANGE.md) | | Basic Moritani / Leader Skills | Ordinary Terror death returns the assigned skill once; own revival offers the existing private optional replacement. Sabotage does not take skills, alliances do not transfer assignments, and mandatory Planetologist/Diplomat disposal excludes those cards from ally retention. This is composition of existing contracts, not a new ruling or resolution of unfinished skill effects. Advanced and other roster/module combinations remain gated. | [Integration contract](MORITANI_LEADER_SKILLS.md) | -| Ecaz Loyalty | Before initial Traitor dealing, randomly set aside one native Ecaz card face up permanently. No Karama effect; no change to its leader disc. Resolving after existing pre-Traitor setup is an explicit sequencing composition. Older dealt saves are not retrofitted; combined Skills/assassination rosters remain gated. | [Official sources and runtime](ECAZ_LOYALTY.md) | +| Ecaz Loyalty | Before initial Traitor dealing, randomly set aside one native Ecaz card face up permanently. No Karama effect; no change to its leader disc. Resolving after existing pre-Traitor setup is an explicit sequencing composition; native ordinary-disc Skills now precede this draw through public assignment. Older dealt saves are not retrofitted; wider Skills/assassination combinations remain gated. | [Official sources and runtime](ECAZ_LOYALTY.md), [native skill composition](LEADER_SKILLS_RUNTIME.md#native-ecaz-ordinary-disc-composition--4-october-2026) | | Sapho battle order | First/last reorders remaining Battle-phase choosers at clean boundaries. Phase priority persists through aftermath; actual legal pairs determine eligibility. Separating the chooser from physical attacker/tie advantage is an explicit implementation interpretation of the card's distinct alternatives, not a dedicated FAQ ruling. Last does not prevent others from choosing the holder as an opponent. | [Runtime scope and source interpretation](SAPHO_BATTLE_ORDER.md) | | Harass & Withdraw | Either slot but neither category; private Prescience answers null. Return uniquely determined own undialed physical forces before weapon losses, canceled by an opposing successful Traitor. Mandatory after-use discard overrides winner retention. Explosion ordering and own-Traitor disposal are explicit timing/specificity inferences, consistent with the recorded Planetologist precedent. | [Runtime and sources](HARASS_WITHDRAW_RUNTIME.md) | | Expansion-faction setup | Selecting Ecaz/Moritani factions does not select their independent three-card variant. Ordinary decks contain 33/35/47 cards; Richese's ten-card cache stays separate. Ecaz allocates six total forces within Imperial Basin sectors 9–11, retaining fourteen reserves. Flexible sector allocation is the literal printed territory constraint; Fremen→Ecaz→Advanced BG order is an implementation inference, while Moritani-last is printed. | [Source and profile contract](EXPANSION_FACTIONS_PROTOTYPE.md) | @@ -180,6 +180,7 @@ See [the cutover and Advanced sequence](MORITANI_ASSASSINATE_LEADERS.md#authorit | Basic CHOAM / Leader Skills | Compose existing ordinary skills with native sales and own-leader Ghola revival. Finish the private optional replacement before the exact suspended sale resumes. The free revival remains completed if the resumed sale is canceled. Basic uses five ordinary leaders and 35 Treachery Cards; Auditor remains Advanced and ineligible for skills. | [Integration and source contracts](CHOAM_LEADER_SKILLS.md) | | Basic Ixians / Leader Skills | Per-origin cyborg range, room-local HMS force routes, rescue before actual cyborg Tank substitution, Rihani before faction substitution and mandatory cleanup compose existing contracts. Ambiguous rescued cyborg reserve origins are explicit player choices. Exact green Ix Specials are Thumper, Harvester and Amal. | [Integration and physical sources](IX_LEADER_SKILLS.md) | | Basic Tleilaxu / Leader Skills | Compose settled native Rihani Face Dancer eligibility, own revival offers and Face Dance death return through the full deck. Zoal copies opposing printed disc value for unmodified Smuggler collection. Advanced foreign gholas and existing skill questions remain gated. | [Integration and sources](TLEILAXU_LEADER_SKILLS.md) | +| Native Ecaz / Leader Skills | Basic/Advanced Ecaz plus classic opponents retains the exact ecaz deck, five native assignment discs, public skills before Loyalty/Traitors and original placement. Ordinary rescue/death/own revival and Ambassador handlers compose. A separately acquired Duke6 may receive the normal Warmaster1, never trained-disc3; surviving release or printed6 death leaves the native trainer intact. Existing-skilled Ecaz paid5 Duke revival preserves the earlier living set-aside result, without authorizing Duke assignment. Advanced Harkonnen and combined Occupy skills remain bounded. | [Native scope and source contracts](LEADER_SKILLS_RUNTIME.md#native-ecaz-ordinary-disc-composition--4-october-2026), [original Duke lifecycle](DUKE_VIDAL_RULES.md) | | Leader Skills | Fourteen physical cards, variant setup order, public/hidden assignment, death return, own-leader revival and captured lower-band effects are sourced. Five battle disciplines, Planetologist movement/Special substitution, Suk Graduate rescues and common lifecycle are prototyped; Rihani, lower Mentat/Bureaucrat/Sandmaster, normal Smuggler and normal Diplomat now have connected follow-ups. Remaining bands and combinations stay missing. Known skilled capture identifies the leader through its unique public card. | [Source contract](LEADER_SKILLS_RULES.md), [capture inference](LEADER_SKILLS_CAPTURE.md), [runtime](LEADER_SKILLS_RUNTIME.md), [Diplomat](DIPLOMAT_DEFENSE.md), [Planetologist](PLANETOLOGIST_RULES.md), [Suk Graduate](SUK_GRADUATE_RULES.md) | | 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) | diff --git a/docs/SPICE_BANKER_RUNTIME.md b/docs/SPICE_BANKER_RUNTIME.md index 1cbebc68..3d058f3d 100644 --- a/docs/SPICE_BANKER_RUNTIME.md +++ b/docs/SPICE_BANKER_RUNTIME.md @@ -27,6 +27,12 @@ Ornithopter invoice as buyer4/Richese4, earned one unavailable Guild Banker spice, and collected it once at native Mentat after ordinary Collection. The front-shield pile stayed separate through Revival and movement; refresh retains Mentat/wallet7/empty pile. No public-mode or deployed acceptance. +The [native Ecaz ordinary-disc profile](LEADER_SKILLS_RUNTIME.md#native-ecaz-ordinary-disc-composition--4-october-2026) +also admits this separate Basic/Advanced opt-in with its original five-disc +training and Ambassador custody. Actual Emperor ordinary shipment pays4 for +two counters and grants native Ecaz Banker one deferred spice without changing +its available wallet in both bands. An Ambassador bank grant is not a payer +payment. Shared-Duke assignment and combined Occupy skills remain separate. ## Normal income local contract — 2 October 2026 diff --git a/game/leader-skill-profile.ts b/game/leader-skill-profile.ts index 3fcc4644..db40fa23 100644 --- a/game/leader-skill-profile.ts +++ b/game/leader-skill-profile.ts @@ -63,6 +63,32 @@ export function advancedMoritaniLeaderSkillsProfile(game: LeaderSkillProfile): b return native; } +/** Ecaz's five native discs; the separate temporarily controlled Duke is not an assignment disc. */ +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 || + game.advancedPreview || game.kullPreview || game.nexusKullPreview || + game.guildBetrayalPreview || game.richeseBetrayalPreview || + game.nexusIxianReplacementPreview || game.nexusIxianBetrayalPreview || + game.nexusHarkonnenBetrayalPreview) return false; + let native = false; + for (let i = 0; i < players.length; i++) { + const player = players[i]; + for (let j = 0; j < i; j++) if (players[j].faction === player.faction) return false; + if (player.faction === 'ecaz') native = true; + else { + if (game.advanced && player.faction === 'harkonnen') return false; + let classic = false; + for (const faction of FACTIONS) + if (faction.id === player.faction && faction.expansion === 'base') { classic = true; break; } + if (!classic) return false; + } + } + return native; +} + /** Native Basic Tleilaxu, including Face Dancers; foreign gholas are Advanced. */ export function basicTleilaxuLeaderSkillsProfile(game: LeaderSkillProfile): boolean { return game.advanced === false && game.expansions.length === 1 && @@ -155,7 +181,7 @@ export function advancedNativeLeaderSkillsProfile(game: LeaderSkillProfile): boo export function nativeExpansionLeaderSkillsProfile(game: LeaderSkillProfile): boolean { return basicExpansionLeaderSkillsProfile(game) || advancedNativeLeaderSkillsProfile(game) || - advancedMoritaniLeaderSkillsProfile(game); + advancedMoritaniLeaderSkillsProfile(game) || nativeEcazLeaderSkillsProfile(game); } /** Shared by rule quotes, controls and minimal legal bot participation. */ diff --git a/game/reference.ts b/game/reference.ts index 495b9caa..0482467c 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -1290,7 +1290,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Before any initial Traitor Cards are drawn, randomly select one of the five ordinary Ecaz Traitor Cards and set it aside face up. Everyone may inspect it. It remains outside the deck for the rest of the game, including later Traitor and Face Dancer draws.', 'Loyalty is automatic and cannot be stopped with Karama. It creates no player choice, payment or acknowledgment. The card names a leader, but does not remove, kill, transfer or otherwise change that leader disc. Duke Vidal has no Traitor Card and is never a candidate.', 'In new Advanced development games with Ecaz, the table displays the selected card before the Traitor choices. Inspect traitor enlarges its identity; refreshing or resuming the saved game keeps the same card. Basic games and games without native Ecaz do not use this power.', - 'Older saved games that have already dealt their Traitor Cards retain that inventory; they do not gain a retroactive Loyalty draw. Ecaz Advanced and expansion starts remain gated, including unfinished combinations with Leader Skills and Moritani assassination.', + 'Older saved games that have already dealt their Traitor Cards retain that inventory; they do not gain a retroactive Loyalty draw. Bounded native Ecaz Leader Skills retain this original draw after public assignment. Public expansion starts, combined Occupy skills and Moritani assassination combinations remain gated.', ], checklist: [ { area: 'Implementation', status: 'Partial', detail: 'One persisted native Ecaz card is selected before genuine initial dealing, excluded from setup and Nexus deck construction, and validated against all known circulating or retired Traitor locations. Older saves stay unchanged. Broader combinations remain gated.', evidence: ['game/ecaz-loyalty.ts', 'game/engine.ts'] }, @@ -2409,7 +2409,7 @@ export const RULE_TOPICS: RuleTopic[] = [ area: 'Implementation', status: 'Partial', detail: - 'Ordinary and typed forces, controlled leaders and Kwisatz return with conserved usage and recovery; only Ecaz may revive Duke. Dead set-aside Duke revival and all expansion interactions remain unfinished.', + 'Ordinary and typed forces, controlled leaders and Kwisatz return with conserved usage and recovery. Ecaz-only dead shared Duke paid revival and physical Ghola return the same living set-aside disc; exceptional custody and broader expansion combinations remain unfinished.', evidence: ['game/engine.ts', 'game/ecaz-duke-revival.ts'], }, { @@ -2838,7 +2838,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Karama can prevent Moritani from acquiring Vidal. That cancellation does not remove him once Moritani already controls him.', 'Ecaz gains Vidal through its Ecaz Ambassador if he is neither in the Tanks, captured nor a ghola. Ecaz keeps him until battle use or Moritani takes him. The Ambassador can instead form a consensual alliance when both factions are unallied; Ecaz may then lend Vidal to that new ally for the turn.', 'Only Ecaz may revive Vidal, including with Ghola Treachery. Ecaz may revive him for five spice regardless of how many of its leaders are in the Tanks. Five spice is his special revival cost; his printed battle strength is six. Ecaz may begin ordinary leader revival when five leaders are in the Tanks, counting Vidal, even if it still holds another leader.', - 'The current implementation supports Moritani acquisition, direct Ecaz Ambassador self-acquisition, consensual Ambassador alliances and one-battle use. Duke loans, reacquisition while already controlled, revival and captured-leader interactions remain unfinished. This shared component does not enable complete Ecaz or Moritani games. Inspection does not commit a battle plan.', + 'The current implementation supports Moritani acquisition, direct Ecaz Ambassador self-acquisition, consensual Ambassador alliances, one-battle use and Ecaz-only paid or physical Ghola revival to living set-aside custody. Native ordinary-disc skills can coexist with a separately controlled Duke, without assigning him a skill. Duke loans, reacquisition while already controlled, shared skill assignment and captured-leader interactions remain unfinished. This shared component does not enable complete Ecaz or Moritani games. Inspection does not commit a battle plan.', ], related: [ 'faction-ecaz', @@ -4476,6 +4476,29 @@ export const RULE_TOPICS: RuleTopic[] = [ { area: 'Verification', status: 'Partial', detail: 'Original native role checks remain. Advanced own/foreign revival, normal versus lower bonus, death/buyback, Face Dance and captured use/execution cases pass with all fourteen cards and current JSON. Fourteen genuine games finish6,620 accepted actions/no rejection/171 JSON continuations, including62 battles and two foreign revivals. Actual manual/four-policy revival and captive revelation/return/execution smoke pass; complete module acceptance remains open.', evidence: ['tests/advanced-tleilaxu-skills.test.ts','tests/tleilaxu-skills-integration.test.ts','tests/tleilaxu-skills-revival.test.ts','tests/rihani-face-dancers.test.ts'] }, ], }, + { + id: 'ecaz-leader-skills', + title: 'Ecaz with Leader Skills', + category: 'Advanced & expansions', + coverage: 'Partial', + developmentStage: 'Prototyped', + summary: 'Five native trained discs, original Ambassador choices and ordinary battle effects, with the separate temporary Duke retained.', + steps: [ + 'Fresh Basic or Advanced native Ecaz with classic opponents retains the ecaz deck and all fourteen skills. Starting cards precede public skill assignments, Loyalty/Traitors and original six-force placement. Five native discs are eligible; the separate Duke is not an assignment disc. Public starts remain gated.', + 'Ordinary native role scores, actual Suk casualties, trainer death and optional own normal-disc revival skills use their original rules. Normal rescue returns one physical casualty; surviving trained rescue keeps one and returns up to two more, without refunding support or creating extra forces.', + '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.', + ], + related: ['leader-skills', 'duke-vidal', 'ecaz-ambassadors', 'spice-banker-income', 'implementation-checklist'], + checklist: [ + {area:'Implementation',status:'Partial',detail:'Native five-disc profile composes original setup, Ambassador, ordinary rescue/death/revival and separate temporary Duke handlers. Combined Occupy skills and shared-Duke assignment remain guarded.',evidence:['game/leader-skill-profile.ts','game/engine.ts']}, + {area:'Player controls',status:'Partial',detail:'Original skill offers, posture, battle slots, rescue, Ambassador acquisition and revival controls are reused. Human390px LQWE8TZM v18 exercises paid token/arrival/acquisition, Duke Worthless battle and original cleanup/refresh.',evidence:['components/leader-skills.tsx','components/duke-vidal.tsx','components/game-table.tsx']}, + {area:'AI',status:'Partial',detail:'Existing legal native choices and all four physical Suk rescues compose. No new strategy or calibration.',evidence:['game/bots.ts','tools/faction-games.ts']}, + {area:'Documentation',status:'Partial',detail:'Ordinary native and temporary Duke scope are separated from unresolved assignment, capture and combined force rules.',evidence:['docs/LEADER_SKILLS_RUNTIME.md','docs/DUKE_VIDAL_RULES.md']}, + {area:'Verification',status:'Partial',detail:'Affected67/67, types/lint/build and twenty controlled rule programs pass. Ten genuine two-through-six-seat Basic/Advanced games complete4,264 accepted actions without rejection,110 JSON continuations and59 battles. Original CLI24HGMJCD v4 retains setup; humanLQWE8TZM v18 retains Collection/wallet11/eight real counters/living set-aside Duke and native Warmaster. Full combinations/assurance/public/deployed acceptance remain open.',evidence:['tests/native-ecaz-skills.test.ts','tests/native-ecaz-duke-skills.test.ts']}, + ], + }, { id: 'richese-leader-skills', title: 'Richese with Leader Skills', @@ -4580,10 +4603,10 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Mentat has an explicit private developer preview for naming a specific weapon before posture and faction powers in supported classic and native skill profiles, including bounded Advanced Moritani. The target privately shows the named held card or chooses another held card. Weirding Way is a nameable default weapon; Chemistry is not. The card stays held and need not enter a Battle Plan. Every answer uses the same private response step; ordinary activation awaits the user’s timing-design decision. Empty hands skip without an answer. Private history is an observation, not a later hand guarantee.', 'Bureaucrat may redirect two spice from a payment of at least five between two other players, once per phase. The payer still pays in full; the recipient receives two less. Declining keeps the use available for a later payment. Paid bribes qualify when transferred, with the remaining share still unavailable until Mentat Pause. Ordinary auctions, Guild income, Richese sales and actual paid Tleilaxu revival income in supported Basic or bounded Advanced native skill profiles have connected payment paths. A separate free-revival award is not part of the paid price and stays with Tleilaxu. Split funding, gifts, other payment families and combined modules remain unfinished.', 'Smuggler records the existing battle-territory spice when plans reveal, capped by the selected leader’s unmodified strength. If that leader survives, it collects automatically even when losing or no forces remain. A captive pays its controller. Pending spice cannot fund the plan. Collection precedes Sandmaster’s addition; a drained pile receives none. Modified strength, multiple positive piles and combined modules remain guarded. Older saved battles keep their original behavior.', - 'Other special Sandmaster relocation remains missing. Normal Banker income has separate fresh classic or supported native source-to-Mentat previews with provisional original-earned-faction custody, not general activation. Diplomat retreat retains its documented conservative outcome order. Native Advanced foreign gholas/Face Dancers and skill-first Moritani assassination follow their bounded contracts; Ecaz/Duke assignment and other combined modules remain unfinished. This prototype does not enable public module starts.', + 'Other special Sandmaster relocation remains missing. Normal Banker income has separate fresh classic or supported native source-to-Mentat previews with provisional original-earned-faction custody, not general activation. Diplomat retreat retains its documented conservative outcome order. Native Advanced foreign gholas/Face Dancers, skill-first Moritani assassination and ordinary-disc Ecaz skills follow their bounded contracts; shared-Duke assignment, combined Occupy skills and other combined modules remain unfinished. This prototype does not enable public module starts.', ...LEADER_SKILL_CARDS.map(card => `${card.name}. Normal: ${card.normal.join(' ')} Skilled battle: ${card.battle.join(' ')}`), ], - related: ['spice-banker-income', 'choam-leader-skills', 'ixian-leader-skills', 'tleilaxu-leader-skills', 'richese-leader-skills', 'moritani-leader-skills', 'setup','choam-modules','implementation-checklist'], + related: ['spice-banker-income', 'choam-leader-skills', 'ixian-leader-skills', 'tleilaxu-leader-skills', 'richese-leader-skills', 'ecaz-leader-skills', 'moritani-leader-skills', 'setup','choam-modules','implementation-checklist'], checklist: [ { area:'Implementation',status:'Partial',detail:'Exact fourteen-card custody, source-ordered setup, public capture, concealment, death and own revival connect five role bonuses, Planetologist movement/Special substitution and Suk Graduate casualty rescue. Rihani inspection/exchange and lower Mentat/Bureaucrat/Sandmaster effects are connected. Smuggler normal reserve shipping, the owned Richese No-Field companion, unmodified battle collection and optional Sandmaster ground-route and native Fremen worm-ride collection are connected. A separately opted-in Mentat preview connects private pre-plan questions and historical observations while ordinary activation awaits the uniform-response decision. Spice Banker lower spending and native Diplomat base Shield/Snooper copying are connected; skilled Diplomat retreat now transfers undialed normal/elite forces before losing casualties in supported battles. Normal Banker income connects only in the separate fresh classic or supported Basic native banker-income profile; other remaining bands and combined modules remain missing. Bureaucrat redirects supported third-party auction, shipment and bribe payments with exact saved once-per-phase use.',evidence:['game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.ts','game/suk-graduate.ts','game/rihani-decipherer.ts','game/mentat-question.ts','game/bureaucrat-payment.ts','game/leader-skill-battle-board.ts','game/smuggler-shipment.ts','game/smuggler-no-field.ts','game/smuggler-battle.ts','game/spice-banker.ts','game/spice-banker-income.ts','game/sandmaster-movement.ts','game/sandmaster-worm.ts','game/diplomat-defense.ts','game/diplomat-retreat.ts','game/engine.ts'] }, { 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'] }, diff --git a/tests/fixture-advanced-native-skills.ts b/tests/fixture-advanced-native-skills.ts index 4492f141..db5ec73b 100644 --- a/tests/fixture-advanced-native-skills.ts +++ b/tests/fixture-advanced-native-skills.ts @@ -10,12 +10,12 @@ import { newRevivalRules } from '../game/revival'; import { nextSpiceBankerIncomeNativeStep } from './fixture-spice-banker-income'; export type AdvancedNativeSkillsOptions = { - family: 'ixians' | 'choam' | 'tleilaxu' | 'moritani' | 'richese'; + family: 'ixians' | 'choam' | 'tleilaxu' | 'moritani' | 'richese' | 'ecaz'; requestedSkill?: LeaderSkillId; skillOwner?: FactionId; opponents?: readonly FactionId[]; bankerIncome?: boolean; - /** Richese also exercises its already-admitted Basic native rule band. */ + /** Richese and Ecaz also exercise their Basic native rule bands. */ rules?: 'basic' | 'advanced'; /** Continue this actual authenticated lobby/setup without replacing its seats. */ initial?: Game; @@ -55,7 +55,7 @@ function withSkillShuffle(skill: LeaderSkillId, ownerIndex: number, initializ * physical skill offers. No played save is converted and no deck is cherry-picked. */ export function initializeAdvancedNativeSkillsSetup(options: AdvancedNativeSkillsOptions): Game { const ownerFaction = options.skillOwner ?? options.family; - const expansion = options.family === 'moritani' ? 'ecaz' : + const expansion = options.family === 'moritani' || options.family === 'ecaz' ? 'ecaz' : options.family === 'choam' || options.family === 'richese' ? 'choam' : 'ix'; let game: Game; if (options.initial) game = structuredClone(options.initial); diff --git a/tests/fixture-ecaz-duke-skills.ts b/tests/fixture-ecaz-duke-skills.ts new file mode 100644 index 00000000..2676fa6f --- /dev/null +++ b/tests/fixture-ecaz-duke-skills.ts @@ -0,0 +1,156 @@ +import assert from 'node:assert/strict'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { leaders, treacheryDeck, type Card } from '../game/cards'; +import { traitorDeck } from '../game/traitors'; +import { DUKE_VIDAL_ID } from '../game/duke-vidal'; +import { validateAmbassadors } from '../game/ecaz-ambassadors'; +import { + advancedNativePlayer as player, advancedNativeStep, assertAdvancedNativeCustody, + advanceAdvancedNativeSkillsToPhase, completedAdvancedNativeSkillsGame, + stageAdvancedNativeSkillBattle, +} from './fixture-advanced-native-skills'; + +export const reloadEcazDukeSkills = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game; +export const ecazDukeSkillsAction = (game: Game, actor: string, action: Action): Game => + applyAction(reloadEcazDukeSkills(game), actor, action); +export const ecazDukeSkillsTrainer = (game: Game) => { + const assignment = game.leaderSkills!.assignments.find(a => a.owner === player(game, 'ecaz').id)!; + assert.equal(assignment.skill, 'warmaster'); + return assignment; +}; + +export function assertEcazDukeSkillsCustody(game: Game): void { + assertAdvancedNativeCustody(game); + const physical = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand), + ...(game.auction?.cards.slice(game.auction.index) ?? [])].map(c => c.id); + assert.deepEqual(physical.sort(), treacheryDeck(game.expansions).map(c => c.id).sort()); + for (const seat of game.players) + assert.deepEqual(seat.leaders.map(l => l.id).sort(), leaders(seat.faction).map(l => l.id).sort()); + assert.equal(player(game, 'ecaz').leaders.length, 5); + assert.equal(game.dukeVidal!.leader.id, DUKE_VIDAL_ID); + assert.equal(game.dukeVidal!.leader.strength, 6); + assert.equal(game.dukeVidal!.leader.faction, 'ecaz'); + assert.equal(game.players.some(p => p.leaders.some(l => l.id === DUKE_VIDAL_ID)), false); + assert.equal(game.leaderSkills!.assignments.some(a => a.leader === DUKE_VIDAL_ID), false); + const traits = [...game.traitorReserve!, ...game.players.flatMap(p => p.traitors), + ...(game.ecazLoyalty?.card ? [game.ecazLoyalty.card] : [])]; + assert.deepEqual(traits.sort(), traitorDeck(game.players, game.expansions.includes('ix')).sort()); + assert.equal(traits.includes(DUKE_VIDAL_ID), false); + validateAmbassadors(game.ecazAmbassadors!); +} + +function responses(state: Game): Game { + let game = state; + for (let i = 0; game.response && i < 100; i++) + game = advancedNativeStep(reloadEcazDukeSkills(game)); + assert.equal(game.response, null); + return game; +} + +/** Original Ecaz setup, original end-of-Revival paid token placement, actual + * entrant shipment and owner acquisition. The only controlled board position + * returns deployed forces to their own reserves and puts the storm at sector18 + * after genuine setup/phase progression. Cards, wallets, discs, traitors and + * the original random Ambassador cohort are never replaced or cherry-picked. */ +export function acquireNativeEcazSkillDuke(rules: 'basic' | 'advanced' = 'advanced'): Game { + let game = completedAdvancedNativeSkillsGame({ + family: 'ecaz', requestedSkill: 'warmaster', opponents: ['emperor'], rules, + }); + const actor = player(game, 'ecaz').id, entrant = player(game, 'emperor').id; + const assignment = structuredClone(ecazDukeSkillsTrainer(game)); + assert.equal(game.dukeVidal!.controller, null); + assertEcazDukeSkillsCustody(game); + game = advanceAdvancedNativeSkillsToPhase(game, 4); + // Explicit conserved position, not a claim of played movement history. + for (const seat of game.players) { + seat.reserves += Object.values(seat.forces).reduce((sum, n) => sum + n, 0); + seat.forces = {}; + if (seat.elites) { + seat.elites.reserves += Object.values(seat.elites.forces).reduce((sum, n) => sum + n, 0); + seat.elites.forces = {}; + } + } + game.storm = 18; + for (let i = 0; game.decision?.kind !== 'ecazPlacement' && i < 100; i++) + game = advancedNativeStep(reloadEcazDukeSkills(game)); + assert.equal(game.decision?.kind, 'ecazPlacement'); + const token = game.ecazAmbassadors!.tokens.find(t => t.effect === 'ecaz')!; + const cohort = structuredClone(game.ecazAmbassadors!.cohort); + const wallet = player(game, 'ecaz').spice; + game = ecazDukeSkillsAction(game, actor, { type: 'decision', token: token.id, territory: 'arrakeen' }); + game = responses(game); + assert.equal(player(game, 'ecaz').spice, wallet - 1); + assert.equal(game.ecazAmbassadors!.tokens.find(t => t.id === token.id)!.zone, 'placed'); + game = ecazDukeSkillsAction(game, actor, { type: 'decision', decline: true }); + game = responses(game); + assert.equal(game.phase, 5); + for (let i = 0; game.active !== entrant && i < 10; i++) { + assert.ok(game.active); + game = responses(ecazDukeSkillsAction(game, game.active, { type: 'endMovement' })); + } + assert.equal(game.active, entrant); + const before = reloadEcazDukeSkills(game); + game = ecazDukeSkillsAction(game, entrant, { type: 'ship', territory: 'arrakeen', sector: 10, amount: 1 }); + game = responses(game); + assert.equal(game.pendingAmbassador!.owner, actor); + assert.equal(game.pendingAmbassador!.entrant, entrant); + assert.equal(viewGame(game, actor).ambassadorEntry!.dukeAcquisition!.blocked, null); + assert.equal(player(game, 'emperor').reserves, player(before, 'emperor').reserves - 1); + assert.equal(player(game, 'emperor').forces['arrakeen:10'], 1); + assert.equal(player(game, 'emperor').spice, player(before, 'emperor').spice - 1); + const disc = structuredClone(game.dukeVidal!.leader); + const shippedPlayers = structuredClone(game.players); + game = ecazDukeSkillsAction(game, actor, { + type: 'decision', event: game.pendingAmbassador!.event, + trigger: true, beneficiary: actor, choice: 'duke', + }); + assert.deepEqual(game.players, shippedPlayers); + assert.deepEqual(game.dukeVidal!.leader, disc); + assert.deepEqual([game.dukeVidal!.controller, game.dukeVidal!.source, game.dukeVidal!.acquiredTurn], + [actor, 'ecaz', game.turn]); + assert.equal(game.pendingAmbassador, null); + assert.equal(game.ecazAmbassadors!.tokens.find(t => t.id === token.id)!.zone, 'supply'); + assert.deepEqual(game.ecazAmbassadors!.cohort, cohort); + assert.deepEqual(ecazDukeSkillsTrainer(game), assignment); + assertEcazDukeSkillsCustody(game); + return game; +} + +/** Controlled subsequent native battle position after real token acquisition. + * Transfer original deck cards, not fabricated cards; original Duke custody and + * its separate normal-disc trainer survive the conserved board relocation. */ +export function stageNativeEcazDukeSkillBattle(state: Game, poison = false): { + game: Game; actor: string; enemy: string; worthless: Card; weapon?: Card; defender: string; +} { + const actor = player(state, 'ecaz').id, enemy = player(state, 'emperor').id; + let game = stageAdvancedNativeSkillBattle(state, actor, enemy); + // Retain the original interrupted movement suffix; only the conserved board + // position is staged. Both armies now have a real pending native battle. + Object.assign(game, { phase: state.phase, active: state.active, order: [...state.order], ready: [...state.ready] }); + for (let i = 0; game.phase === 5 && i < 20; i++) { + assert.ok(game.active); + game = responses(ecazDukeSkillsAction(game, game.active, { type: 'endMovement' })); + } + assert.equal(game.phase, 6); + assert.equal(game.dukeVidal!.controller, actor); + // Explicit controlled chooser order at the genuine Battle boundary gives + // Ecaz the attacker's printed tie-break; neither army or battle is removed. + game.order = [actor, enemy]; + game.active = actor; + const take = (owner: string, kind: Card['kind']): Card => { + const index = game.deck.findIndex(c => c.kind === kind); + assert.ok(index >= 0, 'Transfer an original physical native-deck card.'); + const card = game.deck.splice(index, 1)[0]; + game.players.find(p => p.id === owner)!.hand.push(card); + return card; + }; + const worthless = take(actor, 'worthless'); + const weapon = poison ? take(enemy, 'poison') : undefined; + // The real setup attaches the Emperor's card to first-disc Fenring6, + // leaving this printed5 disc available while the native trainers stay face up. + const defender = player(game, 'emperor').leaders.find(l => l.strength === 5)!; + assert.notEqual(game.leaderSkills!.assignments.find(a => a.owner === enemy)!.leader, defender.id); + assertEcazDukeSkillsCustody(game); + return { game, actor, enemy, worthless, weapon, defender: defender.id }; +} + diff --git a/tests/native-ecaz-duke-skills.test.ts b/tests/native-ecaz-duke-skills.test.ts new file mode 100644 index 00000000..c84c53f3 --- /dev/null +++ b/tests/native-ecaz-duke-skills.test.ts @@ -0,0 +1,150 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, viewGame, type Game } from '../game/engine'; +import { DUKE_VIDAL_ID } from '../game/duke-vidal'; +import { + advancedNativePlayer as player, advancedNativeStep, finishAdvancedNativeSkillAftermath, + openAdvancedNativeSkillBattle, rejectAdvancedNativeAction as reject, +} from './fixture-advanced-native-skills'; +import { + acquireNativeEcazSkillDuke, assertEcazDukeSkillsCustody, + ecazDukeSkillsAction as act, ecazDukeSkillsTrainer as trainer, + reloadEcazDukeSkills as reload, stageNativeEcazDukeSkillBattle, +} from './fixture-ecaz-duke-skills'; + +function resolve(game: Game, actor: string, enemy: string): Game { + game = act(game, actor, { type: 'traitorCall', call: false }); + return act(game, enemy, { type: 'traitorCall', call: false }); +} + +for (const rules of ['basic', 'advanced'] as const) { + void test(`${rules} original Ecaz Ambassador acquisition gives separate Duke6 only the native face-up Warmaster's normal +1`, () => { + const acquired = acquireNativeEcazSkillDuke(rules); + const assignment = structuredClone(trainer(acquired)); + const native = structuredClone(player(acquired, 'ecaz').leaders); + for (const [visible, dial, expectedWinner] of [ + [true, 2, 'ecaz'], [false, 2, 'emperor'], [true, 3, 'emperor'], + ] as const) { + const staged = stageNativeEcazDukeSkillBattle(reload(acquired)); + let game = openAdvancedNativeSkillBattle(reload(staged.game), staged.actor, staged.enemy, !visible); + assert.equal(game.dukeVidal!.controller, staged.actor); + assert.equal(game.dukeVidal!.source, 'ecaz'); + assert.equal(game.dukeVidal!.leader.strength, 6); + assert.equal(player(game, 'ecaz').leaders.find(l => l.id === assignment.leader)!.dead, false); + assert.equal(game.battle!.leaderSkillHidden![staged.actor], !visible); + assert.equal(viewGame(game, staged.actor).leaderSkills!.eligibleLeaders.some(l => l.id === DUKE_VIDAL_ID), false); + game = act(game, staged.actor, { + type: 'battlePlan', leader: DUKE_VIDAL_ID, dial: 0, support: 0, weapon: staged.worthless.id, + }); + game = act(game, staged.enemy, { + type: 'battlePlan', leader: staged.defender, dial, support: dial, + }); + assert.equal(game.battle!.plans[staged.actor].leader, DUKE_VIDAL_ID); + assert.equal(game.battle!.plans[staged.actor].weapon, staged.worthless.id); + const enemyWallet = player(game, 'emperor').spice; + game = resolve(game, staged.actor, staged.enemy); + // Duke6+normal1 ties Captain5+2: Ecaz attacks and wins. Hidden6 loses; + // visible7 loses to5+3. Giving Duke the trainer's selected-disc+3 would + // incorrectly win the last case. Every dial is paid/allocated natively. + assert.equal(game.lastBattleContext!.winner, player(game, expectedWinner).id); + assert.equal(player(game, 'emperor').spice, enemyWallet - (game.advanced ? dial : 0)); + assert.equal(game.dukeVidal!.leader.dead, false); + assert.equal(game.dukeVidal!.leader.deaths, 0); + assert.equal(game.dukeVidal!.leader.usedAt, 'wind_pass'); + assert.deepEqual([game.dukeVidal!.controller, game.dukeVidal!.source, game.dukeVidal!.acquiredTurn], + [null, null, null]); + assert.deepEqual(trainer(game), assignment); + assert.deepEqual(player(game, 'ecaz').leaders, native); + assert.equal(game.leaderSkills!.deck.includes('warmaster'), false); + game = finishAdvancedNativeSkillAftermath(reload(game)); + assert.equal(game.battle, null); + assert.equal(game.decision, null); + assertEcazDukeSkillsCustody(game); + const refreshed = reload(game); + assert.deepEqual(refreshed.dukeVidal, game.dukeVidal); + assert.deepEqual(viewGame(refreshed, staged.actor).dukeVidal, viewGame(game, staged.actor).dukeVidal); + assert.deepEqual(trainer(refreshed), assignment); + } + }); + + void test(`${rules} real Duke weapon death pays printed6 only and never kills or returns his separate native trainer`, () => { + const staged = stageNativeEcazDukeSkillBattle(acquireNativeEcazSkillDuke(rules), true); + const assignment = structuredClone(trainer(staged.game)); + const native = structuredClone(player(staged.game, 'ecaz').leaders); + const skills = structuredClone(staged.game.leaderSkills); + let game = openAdvancedNativeSkillBattle(reload(staged.game), staged.actor, staged.enemy, false); + game = act(game, staged.actor, { + type: 'battlePlan', leader: DUKE_VIDAL_ID, dial: 0, support: 0, weapon: staged.worthless.id, + }); + game = act(game, staged.enemy, { + type: 'battlePlan', leader: staged.defender, dial: 0, support: 0, weapon: staged.weapon!.id, + }); + const wallets = Object.fromEntries(game.players.map(p => [p.id, p.spice])); + game = resolve(game, staged.actor, staged.enemy); + assert.equal(game.lastBattleContext!.winner, staged.enemy); + assert.deepEqual(Object.fromEntries(game.players.map(p => [p.id, p.spice])), + { ...wallets, [staged.enemy]: wallets[staged.enemy] + 6 }); + assert.equal(game.dukeVidal!.leader.dead, true); + assert.equal(game.dukeVidal!.leader.deaths, 1); + assert.equal(game.dukeVidal!.leader.strength, 6); + assert.deepEqual([game.dukeVidal!.controller, game.dukeVidal!.source, game.dukeVidal!.acquiredTurn], + [null, null, null]); + assert.deepEqual(player(game, 'ecaz').leaders, native); + assert.deepEqual(game.leaderSkills, skills); + assert.deepEqual(trainer(game), assignment); + assert.equal(game.leaderSkills!.deck.includes('warmaster'), false); + game = finishAdvancedNativeSkillAftermath(reload(game)); + assert.equal(game.battle, null); + assert.equal(game.decision, null); + assert.equal(player(game, 'emperor').hand.some(c => c.id === staged.weapon!.id), true, + 'Native winner elects to keep the actual weapon.'); + assert.equal(game.discard.some(c => c.id === staged.worthless.id), true, + 'Native loser returns the real played Worthless to discard.'); + assertEcazDukeSkillsCustody(game); + + // Genuine next-turn phase transitions retain the actual battle death and + // original wallet; no fake Tanks placement or hand/deck replacement. + const deathTurn = game.turn; + for (let i = 0; (game.turn === deathTurn || game.phase !== 4 || game.phaseOpening || game.response || game.decision) + && i < 1000; i++) { + if (game.decision?.kind === 'ecazPlacement') + game = act(game, game.decision.player, { type: 'decision', decline: true }); + else game = advancedNativeStep(reload(game)); + } + assert.equal(game.turn, deathTurn + 1); + assert.equal(game.phase, 4); + assert.equal(game.decision, null); + const funds = player(game, 'ecaz').spice; + const option = viewGame(game, staged.actor).revival.leaders.find(l => l.id === DUKE_VIDAL_ID)!; + assert.equal(option.normalCost, 5); + assert.equal(option.cost, 5); + assert.equal(option.affordable, true); + assert.equal(viewGame(game, staged.enemy).revival.leaders.some(l => l.id === DUKE_VIDAL_ID), false); + reject(game, staged.enemy, { type: 'reviveLeader', leader: DUKE_VIDAL_ID }); + game = act(game, staged.actor, { type: 'reviveLeader', leader: DUKE_VIDAL_ID }); + for (let i = 0; (game.response || game.pendingRevival) && i < 100; i++) + game = advancedNativeStep(reload(game)); + assert.equal(game.pendingRevival, null); + assert.equal(player(game, 'ecaz').spice, funds - 5); + assert.equal(player(game, 'ecaz').leaderRevived, true); + assert.equal(game.dukeVidal!.leader.dead, false); + assert.equal(game.dukeVidal!.leader.deaths, 1); + assert.equal(game.dukeVidal!.leader.strength, 6); + assert.deepEqual([game.dukeVidal!.controller, game.dukeVidal!.source, game.dukeVidal!.acquiredTurn], + [null, null, null]); + assert.equal(game.leaderSkills!.offers[staged.actor], undefined); + assert.notEqual(game.decision?.kind, 'leaderSkillRevival'); + assert.deepEqual(trainer(game), assignment); + assert.deepEqual(game.leaderSkills, skills); + assert.equal(viewGame(game, staged.actor).leaderSkills!.eligibleLeaders.some(l => l.id === DUKE_VIDAL_ID), false); + assertEcazDukeSkillsCustody(game); + const refreshed = reload(game); + assert.deepEqual(refreshed.dukeVidal, game.dukeVidal); + // A subsequent original action accepts the refreshed paid-revival custody. + assert.ok(refreshed.players.some(p => !refreshed.ready.includes(p.id))); + const actor = refreshed.players.find(p => !refreshed.ready.includes(p.id))!.id; + game = applyAction(refreshed, actor, { type: 'ready' }); + assertEcazDukeSkillsCustody(game); + assert.deepEqual(trainer(game), assignment); + }); +} diff --git a/tests/native-ecaz-skills.test.ts b/tests/native-ecaz-skills.test.ts new file mode 100644 index 00000000..add847cb --- /dev/null +++ b/tests/native-ecaz-skills.test.ts @@ -0,0 +1,314 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { leaders, treacheryDeck, type Card } from '../game/cards'; +import { traitorDeck } from '../game/traitors'; +import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards'; +import { validateAmbassadors } from '../game/ecaz-ambassadors'; +import { newRevivalRules } from '../game/revival'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { + advancedNativePlayer as player, advancedNativeStep, advanceAdvancedNativeSkillsToPhase, + assertAdvancedNativeCustody, completedAdvancedNativeSkillsGame, + initializeAdvancedNativeSkillsSetup, openAdvancedNativeSkillBattle, + stageAdvancedNativeSkillBattle, finishAdvancedNativeSkillAftermath, +} from './fixture-advanced-native-skills'; + +const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game; +const act = (game: Game, actor: string, action: Action): Game => applyAction(reload(game), actor, action); +const trainer = (game: Game, actor: string) => game.leaderSkills!.assignments.find(a => a.owner === actor)!; +const wallets = (game: Game) => Object.fromEntries(game.players.map(p => [p.id, p.spice])); + +function custody(game: Game): void { + assertAdvancedNativeCustody(game); + const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand), + ...(game.auction?.cards.slice(game.auction.index) ?? [])]; + assert.deepEqual(cards.map(c => c.id).sort(), treacheryDeck(['ecaz']).map(c => c.id).sort()); + assert.deepEqual(game.players.flatMap(p => p.leaders.map(l => l.id)).sort(), + game.players.flatMap(p => leaders(p.faction).map(l => l.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()); + if (game.ecazAmbassadors) validateAmbassadors(game.ecazAmbassadors); +} + +function take(game: Game, actor: string, matches: (card: Card) => boolean): Card { + const index = game.deck.findIndex(matches); + assert.ok(index >= 0, 'Controlled card staging transfers a real remaining native card.'); + const card = game.deck.splice(index, 1)[0]; + game.players.find(p => p.id === actor)!.hand.push(card); + return card; +} + +function enemyLeader(game: Game): string { + const enemy = player(game, 'emperor'); + const selected = enemy.leaders.filter(l => !l.dead && l.id !== trainer(game, enemy.id).leader) + .sort((a, b) => a.strength - b.strength)[0]; + assert.ok(selected); + return selected.id; +} + +function resolve(game: Game, actor: string, target: string): Game { + return act(act(game, actor, { type: 'traitorCall', call: false }), target, { type: 'traitorCall', call: false }); +} + +function responses(state: Game): Game { + let game = state; + for (let i = 0; (game.response || game.pendingTreacheryDiscard) && i < 100; i++) game = advancedNativeStep(game); + assert.equal(game.response, null); + return game; +} + +void test('native Basic and Advanced Ecaz deal original starting cards, public skills, loyalty and traitors before six-force placement', () => { + for (const rules of ['basic', 'advanced'] as const) { + let game = initializeAdvancedNativeSkillsSetup({ family: 'ecaz', requestedSkill: 'warmaster', rules }); + const actor = player(game, 'ecaz').id; + assert.equal(game.setupStage, 'leaderSkills'); + assert.deepEqual(game.players.map(p => p.hand.length), [1, 1, 1]); + assert.deepEqual(game.players.map(p => p.traitorChoices), [[], [], []]); + assert.equal(player(game, 'ecaz').reserves, 20); + const skills = [...game.leaderSkills!.deck, ...Object.values(game.leaderSkills!.offers).flatMap(o => o.cards)]; + assert.deepEqual(skills.sort(), LEADER_SKILL_CARDS.map(c => c.id).sort()); + assert.deepEqual(viewGame(game, actor).leaderSkills!.eligibleLeaders.map(l => l.id).sort(), + leaders('ecaz').map(l => l.id).sort()); + for (const seat of game.players) { + const own = viewGame(game, seat.id).leaderSkills!; + const offer = own.offer!; + const skill = seat.id === actor ? 'warmaster' : offer.cards.find(c => !own.unavailableSkills?.[c])!; + game = act(game, seat.id, { type: 'leaderSkill', event: offer.event, skill, leader: own.eligibleLeaders[0].id }); + } + assert.equal(game.setupStage, 'traitors'); + assert.equal(trainer(game, actor).skill, 'warmaster'); + for (const seat of game.players) { + assert.equal(seat.traitorChoices.length, 4); + assert.equal(viewGame(game, seat.id).leaderSkills!.assignments.find(a => a.owner === actor)!.leader, trainer(game, actor).leader); + } + if (rules === 'advanced') { + const loyal = game.ecazLoyalty!.card!; + assert.ok(leaders('ecaz').some(l => l.id === loyal)); + assert.equal([...game.traitorReserve!, ...game.players.flatMap(p => p.traitorChoices)].includes(loyal), false); + assert.equal(player(game, 'ecaz').leaders.find(l => l.id === loyal)!.dead, false); + for (const seat of game.players) assert.deepEqual(viewGame(game, seat.id).ecazLoyalty, { player: actor, card: loyal }); + } else assert.equal(viewGame(game, actor).ecazLoyalty, null); + custody(game); + for (const seat of game.players) { + const choice = viewGame(game, seat.id).players.find(p => p.id === seat.id)!.traitorChoices![0]; + assert.ok(choice); + game = act(game, seat.id, { type: 'traitor', leader: choice }); + } + assert.equal(game.setupStage, 'forces'); + assert.deepEqual(game.players.map(p => p.traitors.length), [1, 1, 1]); + assert.equal(player(game, 'ecaz').reserves, 20); + game = act(game, actor, { type: 'ecazSetup', placements: { 'imperial_basin:9': 2, 'imperial_basin:10': 4 } }); + assert.equal(game.status, 'playing'); + assert.equal(game.phase, 0); + assert.equal(player(game, 'ecaz').reserves, 14); + assert.deepEqual(player(game, 'ecaz').forces, { 'imperial_basin:9': 2, 'imperial_basin:10': 4 }); + custody(game); + } +}); + +void test('native Ecaz normal Poison role and selected skilled role change the winner without increasing the printed death bounty', () => { + for (const rules of ['basic', 'advanced'] as const) { + const setup = completedAdvancedNativeSkillsGame({ family: 'ecaz', requestedSkill: 'master-of-assassins', rules }); + const actor = player(setup, 'ecaz').id, target = player(setup, 'emperor').id; + for (const hidden of [false, true]) { + let game = stageAdvancedNativeSkillBattle(setup, actor, target); + const poison = take(game, actor, c => c.kind === 'poison'); + const selected = hidden ? player(game, 'ecaz').leaders.find(l => l.id === trainer(game, actor).leader)! + : player(game, 'ecaz').leaders.find(l => l.id !== trainer(game, actor).leader && l.strength === 4)!; + assert.equal(selected.strength, 4); + const enemy = player(game, 'emperor').leaders.find(l => l.id === enemyLeader(game))!; + const funds = wallets(game), enemyReserves = player(game, 'emperor').reserves; + const normalSuk = !hidden && trainer(game, target).skill === 'suk-graduate' ? 1 : 0; + game = openAdvancedNativeSkillBattle(game, actor, target, hidden); + game = act(game, actor, { type: 'battlePlan', dial: 1, support: rules === 'advanced' ? 1 : 0, leader: selected.id, weapon: poison.id }); + game = act(game, target, { type: 'battlePlan', dial: 7, support: rules === 'advanced' ? 7 : 0, leader: enemy.id }); + game = finishAdvancedNativeSkillAftermath(resolve(game, actor, target)); + assert.equal(game.lastBattleContext!.winner, hidden ? actor : target); + assert.equal(player(game, 'ecaz').leaders.find(l => l.id === selected.id)!.dead, false); + assert.equal(player(game, 'emperor').leaders.find(l => l.id === enemy.id)!.dead, true); + assert.equal(player(game, 'ecaz').spice, funds[actor] - (rules === 'advanced' ? 1 : 0) + (hidden ? enemy.strength : 0)); + assert.equal(player(game, 'emperor').spice, funds[target] - (rules === 'advanced' ? 7 : 0) + (hidden ? 0 : enemy.strength)); + assert.equal(player(game, 'ecaz').tanks, hidden ? 1 : 8); + assert.equal(player(game, 'ecaz').forces['wind_pass:14'] ?? 0, hidden ? 7 : 0); + assert.equal(player(game, 'emperor').tanks, hidden ? 8 : 7 - normalSuk); + assert.equal(player(game, 'emperor').reserves, enemyReserves + normalSuk); + assert.equal(game.discard.filter(c => c.id === poison.id).length, hidden ? 0 : 1); + assert.equal(player(game, 'ecaz').hand.some(c => c.id === poison.id), hidden); + custody(game); + } + } +}); + +void test('native Ecaz Suk uses actual ordinary casualties: face-up one-to-reserves versus selected skilled keep-one and return-two', () => { + for (const rules of ['basic', 'advanced'] as const) { + const setup = completedAdvancedNativeSkillsGame({ family: 'ecaz', requestedSkill: 'suk-graduate', rules }); + const actor = player(setup, 'ecaz').id, target = player(setup, 'emperor').id; + for (const hidden of [false, true]) { + let game = stageAdvancedNativeSkillBattle(setup, actor, target); + const selected = hidden ? trainer(game, actor).leader + : player(game, 'ecaz').leaders.find(l => l.id !== trainer(game, actor).leader && l.strength === 4)!.id; + const enemy = enemyLeader(game), funds = wallets(game), reserves = player(game, 'ecaz').reserves; + game = openAdvancedNativeSkillBattle(game, actor, target, hidden); + game = act(game, actor, { type: 'battlePlan', dial: 5, support: rules === 'advanced' ? 5 : 0, leader: selected }); + game = act(game, target, { type: 'battlePlan', dial: 0, support: 0, leader: enemy }); + game = finishAdvancedNativeSkillAftermath(resolve(game, actor, target)); + assert.equal(game.lastBattleContext!.winner, actor); + assert.equal(player(game, 'ecaz').spice, funds[actor] - (rules === 'advanced' ? 5 : 0)); + if (hidden) { + assert.equal(game.decision?.kind, 'sukRescue'); + if (game.decision?.kind !== 'sukRescue') throw new Error('Expected native ordinary casualty rescue.'); + assert.deepEqual(game.pendingSukRescue!.losses, { normal: 5, elite: 0, paidNormal: rules === 'advanced' ? 5 : 0, paidElite: 0 }); + const pending = game; + const choice = game.decision.options.findIndex(o => o.normal === 3 && o.elite === 0 && o.kept?.key === 'wind_pass:14'); + assert.ok(choice >= 0); + game = finishAdvancedNativeSkillAftermath(act(game, actor, { type: 'decision', event: game.decision.event, choice })); + assert.equal(player(game, 'ecaz').tanks, 2); + assert.equal(player(game, 'ecaz').forces['wind_pass:14'], 4); + assert.equal(player(game, 'ecaz').reserves, reserves + 2); + for (const difficulty of DIFFICULTIES) { + const own = viewGame(reload(pending), actor); + own.players.find(p => p.id === actor)!.bot = difficulty; + const rescued = finishAdvancedNativeSkillAftermath(act(pending, actor, botActions(own)[0])); + assert.equal(player(rescued, 'ecaz').tanks, 2); + assert.equal(player(rescued, 'ecaz').spice, funds[actor] - (rules === 'advanced' ? 5 : 0)); + assert.equal(player(rescued, 'ecaz').forces['wind_pass:14'], 4); + assert.equal(player(rescued, 'ecaz').reserves, reserves + 2); + custody(rescued); + } + } else { + assert.equal(player(game, 'ecaz').tanks, 4); + assert.equal(player(game, 'ecaz').forces['wind_pass:14'], 3); + assert.equal(player(game, 'ecaz').reserves, reserves + 1); + } + assert.equal(player(game, 'emperor').tanks, 8); + custody(game); + } + } +}); + +void test('real native Ecaz trainer death returns its skill; eligible own ordinary and physical Ghola revival offer optional replacement', () => { + for (const rules of ['basic', 'advanced'] as const) { + const setup = completedAdvancedNativeSkillsGame({ family: 'ecaz', requestedSkill: 'warmaster', rules }); + const actor = player(setup, 'ecaz').id, target = player(setup, 'emperor').id; + const assigned = structuredClone(trainer(setup, actor)); + let dead = stageAdvancedNativeSkillBattle(setup, actor, target); + const poison = take(dead, target, c => c.kind === 'poison'); + const printed = player(dead, 'ecaz').leaders.find(l => l.id === assigned.leader)!.strength; + const funds = wallets(dead); + dead = openAdvancedNativeSkillBattle(dead, actor, target); + dead = act(dead, actor, { type: 'battlePlan', dial: 0, support: 0, leader: assigned.leader }); + dead = act(dead, target, { type: 'battlePlan', dial: 0, support: 0, leader: enemyLeader(dead), weapon: poison.id }); + dead = finishAdvancedNativeSkillAftermath(resolve(dead, actor, target)); + assert.equal(dead.lastBattleContext!.winner, target); + assert.equal(player(dead, 'ecaz').leaders.find(l => l.id === assigned.leader)!.dead, true); + assert.equal(dead.leaderSkills!.assignments.some(a => a.owner === actor), false); + assert.equal(dead.leaderSkills!.deck.filter(c => c === assigned.skill).length, 1); + assert.deepEqual(wallets(dead), { ...funds, [target]: funds[target] + printed }); + assert.equal(player(dead, 'ecaz').tanks, 8); + custody(dead); + for (const mode of ['ordinary', 'ghola'] as const) { + let game = reload(dead); + // Explicit conserved Revival position, not a claimed played turn history. + // Ordinary revival stages the other four original discs dead to open the + // native five-disc first cohort. Ghola instead uses only the actual death. + Object.assign(game, { phase: 4, active: null, ready: [], decision: null, response: null, + phaseOpening: null, revivalRules: newRevivalRules() }); + for (const seat of game.players) seat.leaderRevived = false; + if (mode === 'ordinary') for (const leader of player(game, 'ecaz').leaders) if (leader.id !== assigned.leader) { + leader.dead = true; leader.deaths = 1; + } + const before = wallets(game); + let ghola: Card | undefined; + if (mode === 'ordinary') { + assert.equal(viewGame(game, actor).revival.leaders.find(l => l.id === assigned.leader)!.cost, printed); + game = act(game, actor, { type: 'reviveLeader', leader: assigned.leader }); + } else { + ghola = take(game, actor, c => c.effect === 'ghola'); + assert.ok(viewGame(game, actor).ghola.leaders.some(l => l.id === assigned.leader)); + game = responses(act(game, actor, { type: 'card', card: ghola.id, leader: assigned.leader })); + } + assert.equal(game.decision?.kind, 'leaderSkillRevival'); + assert.equal(player(game, 'ecaz').leaders.find(l => l.id === assigned.leader)!.dead, false); + assert.equal(player(game, 'ecaz').leaders.find(l => l.id === assigned.leader)!.deaths, 1); + assert.equal(player(game, 'ecaz').leaderRevived, mode === 'ordinary'); + assert.deepEqual(wallets(game), { ...before, [actor]: before[actor] - (mode === 'ordinary' ? printed : 0) }); + if (ghola) { + assert.equal(player(game, 'ecaz').hand.some(c => c.id === ghola!.id), false); + assert.equal(game.discard.filter(c => c.id === ghola!.id).length, 1); + } + const event = viewGame(game, actor).leaderSkills!.offer!.event; + const declined = act(game, actor, { type: 'leaderSkill', event, mode: 'decline' }); + assert.equal(declined.leaderSkills!.assignments.some(a => a.owner === actor), false); + assert.equal(player(declined, 'ecaz').leaders.find(l => l.id === assigned.leader)!.dead, false); + custody(declined); + game = act(game, actor, { type: 'leaderSkill', event, mode: 'draw' }); + const own = viewGame(game, actor).leaderSkills!; + const offered = own.offer!.cards; + assert.equal(offered.length, 2); + const skill = offered.find(c => !own.unavailableSkills?.[c])!; + game = act(game, actor, { type: 'leaderSkill', event, skill, leader: assigned.leader }); + assert.deepEqual(trainer(game, actor), { owner: actor, leader: assigned.leader, skill }); + assert.equal(game.leaderSkills!.deck.includes(offered.find(c => c !== skill)!), true); + assert.deepEqual(wallets(game), wallets(declined)); + custody(game); + } + } +}); + +void test('original Emperor Ambassador charges placement then pays five from the Bank and resumes the real entrant with Ecaz training intact', () => { + for (const rules of ['basic', 'advanced'] as const) { + let game = completedAdvancedNativeSkillsGame({ family: 'ecaz', requestedSkill: 'warmaster', rules }); + const actor = player(game, 'ecaz').id, entrant = player(game, 'atreides').id; + const assigned = structuredClone(trainer(game, actor)); + const originalTokens = game.ecazAmbassadors!.tokens.map(t => ({ id: t.id, effect: t.effect })); + // Explicit controlled, conserved post-setup cohort and board position. This + // is not natural Ambassador history or a second source of fixed entropy. + // Swap custody only: retain all eleven original token identities/effects. + const state = game.ecazAmbassadors!; + const token = state.tokens.find(t => t.effect === 'emperor')!; + if (!state.cohort.includes(token.id)) { + const replaced = state.tokens.find(t => t.id === state.cohort[0])!; + state.cohort[0] = token.id; + replaced.zone = 'pool'; + token.zone = 'supply'; + } + for (const seat of game.players) { + seat.reserves += Object.values(seat.forces).reduce((sum, n) => sum + n, 0); + seat.forces = {}; + if (seat.elites) { + seat.elites.reserves += Object.values(seat.elites.forces).reduce((sum, n) => sum + n, 0); + seat.elites.forces = {}; + } + } + custody(game); + game = advanceAdvancedNativeSkillsToPhase(game, 4); + const before = wallets(game); + for (const seat of game.players) game = act(game, seat.id, { type: 'ready' }); + assert.equal(game.decision?.kind, 'ecazPlacement'); + game = responses(act(game, actor, { type: 'decision', token: token.id, territory: 'carthag' })); + assert.equal(player(game, 'ecaz').spice, before[actor] - 1); + assert.equal(game.ecazAmbassadors!.tokens.find(t => t.id === token.id)!.location, 'carthag'); + game = act(game, actor, { type: 'decision', decline: true }); + for (let i = 0; (game.phaseOpening || game.response || game.decision || game.active !== entrant) && i < 100; i++) game = advancedNativeStep(game); + const entrantFunds = player(game, 'atreides').spice, entrantReserves = player(game, 'atreides').reserves; + game = responses(act(game, entrant, { type: 'ship', territory: 'carthag', sector: 11, amount: 1 })); + assert.equal(game.decision?.kind, 'ecazAmbassador'); + assert.equal(viewGame(game, actor).ambassadorEntry!.stage, 'offer'); + game = responses(act(game, actor, { type: 'decision', event: game.pendingAmbassador!.event, trigger: true, beneficiary: actor })); + assert.equal(player(game, 'ecaz').spice, before[actor] - 1 + 5); + assert.equal(player(game, 'atreides').spice, entrantFunds - 1); + assert.equal(player(game, 'atreides').reserves, entrantReserves - 1); + assert.equal(player(game, 'atreides').forces['carthag:11'], 1); + assert.equal(game.ecazAmbassadors!.tokens.find(t => t.id === token.id)!.zone, 'used'); + assert.deepEqual(game.ecazAmbassadors!.tokens.map(t => ({ id: t.id, effect: t.effect })), originalTokens); + assert.deepEqual(trainer(game, actor), assigned); + assert.equal(game.active, entrant); + assert.equal(player(game, 'atreides').shipped, true); + game = act(game, entrant, { type: 'endMovement' }); + assert.notEqual(game.active, entrant); + custody(game); + } +}); diff --git a/tools/faction-games.ts b/tools/faction-games.ts index 88a0c413..a0b46c59 100644 --- a/tools/faction-games.ts +++ b/tools/faction-games.ts @@ -36,7 +36,7 @@ import { privateOutputDirectory, sourceSnapshot } from './verification'; const DEFAULT_SEED = 20_260_926; const DEFAULT_MAX_ACTIONS = 3_500; const DIFFICULTIES = ['Easy', 'Medium', 'Hard', 'Brutal'] as const; -type Profile = 'base' | 'choam' | 'ecaz' | 'ecaz-treachery' | 'ecaz-occupy' | 'moritani-assassinate' | 'stronghold-factions' | 'combined' | 'combined-nexus' | 'combined-homeworld-nexus' | 'homeworld' | 'nexus' | 'homeworld-nexus' | 'choam-roster' | 'ecaz-roster' | 'ix-roster' | 'choam-nexus' | 'ecaz-nexus' | 'ix-nexus' | 'moritani-skills' | 'tleilaxu-skills' | 'ix-skills' | 'choam-skills' | 'richese-skills'; +type Profile = 'base' | 'choam' | 'ecaz' | 'ecaz-treachery' | 'ecaz-occupy' | 'moritani-assassinate' | 'stronghold-factions' | 'combined' | 'combined-nexus' | 'combined-homeworld-nexus' | 'homeworld' | 'nexus' | 'homeworld-nexus' | 'choam-roster' | 'ecaz-roster' | 'ix-roster' | 'choam-nexus' | 'ecaz-nexus' | 'ix-nexus' | 'moritani-skills' | 'tleilaxu-skills' | 'ix-skills' | 'choam-skills' | 'richese-skills' | 'ecaz-skills'; type Rules = 'basic' | 'advanced'; type Scenario = { @@ -293,6 +293,16 @@ const RICHESE_SKILLS_SCENARIOS: readonly Scenario[] = [ roster: (['richese', 'emperor', 'guild', 'harkonnen', 'fremen', 'beneGesserit'] as FactionId[]).slice(0, players), })), ]; +const ECAZ_SKILLS_SCENARIOS: readonly Scenario[] = [ + ...[2, 3, 4, 5, 6].map((players): Scenario => ({ + ordinal: 283 + players - 2, profile: 'ecaz-skills', rules: 'basic', expansions: ['ecaz'], + roster: (['ecaz', 'emperor', 'guild', 'harkonnen', 'fremen', 'beneGesserit'] as FactionId[]).slice(0, players), + })), + ...[2, 3, 4, 5, 6].map((players): Scenario => ({ + ordinal: 288 + players - 2, profile: 'ecaz-skills', rules: 'advanced', expansions: ['ecaz'], + roster: (['ecaz', 'emperor', 'guild', 'atreides', 'fremen', 'beneGesserit'] as FactionId[]).slice(0, players), + })), +]; const STRONGHOLD_FACTIONS_ROSTER: readonly FactionId[] = [ 'ixians', 'choam', 'emperor', 'fremen', 'harkonnen', 'beneGesserit', ]; @@ -326,7 +336,7 @@ const STRONGHOLD_FACTIONS_SCENARIOS: readonly Scenario[] = [ roster: (['ixians', 'tleilaxu', 'choam', 'richese', 'emperor', 'fremen'] as FactionId[]).slice(0, players), })), ]; -const skillProfile = (profile: string) => profile === 'moritani-skills' || profile === 'tleilaxu-skills' || profile === 'ix-skills' || profile === 'choam-skills' || profile === 'richese-skills'; +const skillProfile = (profile: string) => profile === 'moritani-skills' || profile === 'tleilaxu-skills' || profile === 'ix-skills' || profile === 'choam-skills' || profile === 'richese-skills' || profile === 'ecaz-skills'; type TraceEntry = { attempt: number; @@ -364,12 +374,13 @@ type Result = { function usage() { return ( 'Usage: node --import tsx tools/faction-games.ts --out NEW_PRIVATE_DIR ' + - '[--seed UINT32] [--profile all|base|choam|ecaz|ecaz-treachery|ecaz-occupy|moritani-assassinate|stronghold-factions|combined|combined-nexus|combined-homeworld-nexus|homeworld|nexus|homeworld-nexus|choam-roster|ecaz-roster|ix-roster|choam-nexus|ecaz-nexus|ix-nexus|moritani-skills|tleilaxu-skills|ix-skills|choam-skills|richese-skills] ' + + '[--seed UINT32] [--profile all|base|choam|ecaz|ecaz-treachery|ecaz-occupy|moritani-assassinate|stronghold-factions|combined|combined-nexus|combined-homeworld-nexus|homeworld|nexus|homeworld-nexus|choam-roster|ecaz-roster|ix-roster|choam-nexus|ecaz-nexus|ix-nexus|moritani-skills|tleilaxu-skills|ix-skills|choam-skills|richese-skills|ecaz-skills] ' + '[--rules both|basic|advanced] [--players all|2|3|4|5|6] [--max-actions POSITIVE] ' + '[--resume FAILED_GAME.json]\n' + 'Runs genuine setup and gameplay offline. Default/all keeps the six expansion samples; combined-nexus and combined-homeworld-nexus add five/six-seat Basic/Advanced all-expansion samples. Base, Homeworld, Nexus, Homeworld-Nexus, expansion roster, paired expansion Nexus, Ecaz card variant, Advanced Moritani assassination and skill profiles select their documented rosters. Stronghold-factions is Advanced only: Ixians + CHOAM, native Ixians + Tleilaxu, or native CHOAM + Richese with classic opponents, two through six seats; mixed four-native rosters also run at four through six seats. Selected Ix/CHOAM decks determine the canonical 47/35 Treachery Cards; Richese has a separate ten-card cache and all samples have six separate Stronghold Cards without other modules. Scenario names identify their native roster. --players requires a supported profile. Output must be a new private directory outside the checkout.' + '\nEcaz-occupy is Advanced only: native Ecaz plus classic/Ixian/Tleilaxu/CHOAM/Richese paired or mixed rosters, two through six seats; selected E3/E1/E2 sources determine ordinary33/47/35 cards. Explicit independent three-card samples additionally use36/50/38. Richese marker-only or ordinary pools retain original reveal timing; own mixed No-Field dialing, whole-plan inspection and optional overlays remain guarded.' + '\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.' ); } @@ -427,7 +438,7 @@ function scenarioName(scenario: Scenario) { function parseProfile(value: string | undefined) { const profile = value ?? 'all'; - if (!['all', 'base', 'choam', 'ecaz', 'ecaz-treachery', 'ecaz-occupy', 'moritani-assassinate', 'stronghold-factions', 'combined', 'combined-nexus', 'combined-homeworld-nexus', 'homeworld', 'nexus', 'homeworld-nexus', 'choam-roster', 'ecaz-roster', 'ix-roster', 'choam-nexus', 'ecaz-nexus', 'ix-nexus', 'moritani-skills', 'tleilaxu-skills', 'ix-skills', 'choam-skills', 'richese-skills'].includes(profile)) + if (!['all', 'base', 'choam', 'ecaz', 'ecaz-treachery', 'ecaz-occupy', 'moritani-assassinate', 'stronghold-factions', 'combined', 'combined-nexus', 'combined-homeworld-nexus', 'homeworld', 'nexus', 'homeworld-nexus', 'choam-roster', 'ecaz-roster', 'ix-roster', 'choam-nexus', 'ecaz-nexus', 'ix-nexus', 'moritani-skills', 'tleilaxu-skills', 'ix-skills', 'choam-skills', 'richese-skills', 'ecaz-skills'].includes(profile)) throw new Error('--profile must name a documented base, module, expansion or native skill sample profile.'); return profile as Profile | 'all'; } @@ -541,7 +552,7 @@ function resumedGame(path: string) { game.mentatQuestionPreview ) throw new Error('--resume sample scenarios exclude unsupported optional modules.'); - const scenario = [...SCENARIOS, ...COMBINED_NEXUS_SCENARIOS, ...COMBINED_HOMEWORLD_NEXUS_SCENARIOS, ...BASE_SCENARIOS, ...MODULE_SCENARIOS, ...EXPANSION_ROSTER_SCENARIOS, ...ECAZ_TREACHERY_SCENARIOS, ...ECAZ_OCCUPY_SCENARIOS, ...PAIRED_NEXUS_SCENARIOS, ...MORITANI_ASSASSINATE_SCENARIOS, ...STRONGHOLD_FACTIONS_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...IX_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS, ...RICHESE_SKILLS_SCENARIOS].find( + const scenario = [...SCENARIOS, ...COMBINED_NEXUS_SCENARIOS, ...COMBINED_HOMEWORLD_NEXUS_SCENARIOS, ...BASE_SCENARIOS, ...MODULE_SCENARIOS, ...EXPANSION_ROSTER_SCENARIOS, ...ECAZ_TREACHERY_SCENARIOS, ...ECAZ_OCCUPY_SCENARIOS, ...PAIRED_NEXUS_SCENARIOS, ...MORITANI_ASSASSINATE_SCENARIOS, ...STRONGHOLD_FACTIONS_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...IX_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS, ...RICHESE_SKILLS_SCENARIOS, ...ECAZ_SKILLS_SCENARIOS].find( (candidate) => (candidate.profile === 'homeworld' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.homeworlds && (candidate.profile === 'nexus' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'choam-nexus' || candidate.profile === 'ecaz-nexus' || candidate.profile === 'ix-nexus' || candidate.profile === 'combined-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.nexusCards && @@ -782,7 +793,8 @@ async function main() { : profile === 'tleilaxu-skills' ? TLEILAXU_SKILLS_SCENARIOS : profile === 'ix-skills' ? IX_SKILLS_SCENARIOS : profile === 'choam-skills' ? CHOAM_SKILLS_SCENARIOS - : profile === 'richese-skills' ? RICHESE_SKILLS_SCENARIOS : SCENARIOS; + : profile === 'richese-skills' ? RICHESE_SKILLS_SCENARIOS + : profile === 'ecaz-skills' ? ECAZ_SKILLS_SCENARIOS : SCENARIOS; const selected = resume ? [resume.scenario] : requestedSamples.filter( diff --git a/tools/start-prototype.ts b/tools/start-prototype.ts index e25cf026..0859301d 100644 --- a/tools/start-prototype.ts +++ b/tools/start-prototype.ts @@ -24,7 +24,7 @@ async function main() { console.log( 'Usage: node --import tsx tools/start-prototype.ts --profile ix|discovery|leader-skills|banker-income|factions|stronghold-factions|nexus|moritani-assassinate|ecaz-treachery|ecaz-occupy|semuta|kull|richese-betrayal|nexus-kull|guild-betrayal|ixian-replacement|ixian-betrayal|harkonnen-betrayal --db PATH --room CODE --version NUMBER --out /private/new-directory\nStarts only a fresh ready lobby for the selected development prototype. Stronghold-factions opts a fresh ready 2–6-seat Advanced roster with at least one selected native Ixians, Tleilaxu, CHOAM or Richese faction and otherwise classic opponents into Stronghold Cards alone. Select one or both distinct Ix/CHOAM decks; each native faction requires its family deck. The selected decks supply 47 Ix or combined, or 35 CHOAM canonical physical Treachery Cards, with a separate ten-card Richese cache when Richese plays. Native setup choices, HMS placement, Face Dancers, Richese cache and CHOAM Auditor remain genuine; custody starts unowned and is claimed by actual END Mentat. It does not redeal, reset or retrofit a saved game. Banker-income opts a fresh 2–6-seat classic Basic/Advanced base-deck roster, supported Basic Ixian/Tleilaxu/CHOAM/Moritani skill roster with its single required deck, or Advanced native Ixians, Tleilaxu and/or CHOAM plus classic roster with distinct required family decks, into automatic deferred normal income and native Mentat collection with all fourteen Leader Skills. Original bank payer costs exclude the separate Tleilaxu free-revival award; already-earned spice stays with its original faction under the existing provisional policy. Nexus enables the existing private Nexus setup. Moritani-assassinate explicitly opts into the bounded Advanced assassination preview. Semuta opts a fresh Richese roster into bounded clean-discard reactions; an included Ix deck supplies physical Thumper without opening public starts. Kull opts a fresh eligible CHOAM roster into the user-selected Karama interception preview. Richese-betrayal opts a fresh paired CHOAM/Richese Nexus roster into cache-purchase veto and sale-bank diversion. Nexus-kull opts a fresh CHOAM-plus-classic roster with CHOAM/Ix decks and Nexus into source-aware printed or any-Treachery-fueled Cunning Kull. Guild-betrayal opts a fresh classic roster with native Guild into full funded shipment-payment replacement; it preserves an explicitly enabled Homeworld module for Junction routes. Ixian-replacement opts a fresh classic Basic/Advanced base-deck roster, or Basic Tleilaxu plus classic opponents with the original Ix47 deck and no optional Sandtrout, into the exact normal-auction purchased-card replacement with Nexus alone. Native Ixians, Harkonnen buyers and special purchase sources remain unsupported. Ixian-betrayal opts a fresh 2–6-seat Basic/Advanced native Ixian plus classic/Tleilaxu roster with Ix 47-card Treachery and Nexus only into prevention of one actual native Bidding draw or Advanced Technology exchange, after native Karama counters. Harkonnen-betrayal opts a fresh 2–6-seat classic Basic/Advanced roster with native Harkonnen, base 33-card Treachery and Nexus alone into cancellation of an actual declared personal or allied Harkonnen Traitor Card after native counters, physical retirement and shuffle, and one private actual Mentat replacement. Modules, overlays, public starts and old-game retrofits remain excluded. Nexus is not dealt initially: a qualifying alliance requires at least three seats; two unallied receivers require four. Backs up all rooms first, preserves sessions and existing games, and rejects stale versions. Normal game-start and publication gates remain closed.', '\nEcaz-occupy (--profile ecaz-occupy) opts only a fresh ready 2–6-seat Advanced lobby with native Ecaz and classic/Ixian/Tleilaxu/CHOAM/Richese opponents (optional native Moritani). Distinct selected decks include ecaz and each native ix/choam family; 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. 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. Explicit --mentat-question stays private; banker-income additionally supports native Richese and exact Advanced donor bank legs, never ordinary seller/allowed Emperor income. Basic auction recipients remain unchanged. Public activation, exhausted-cache counts, native Ixian Technology on Richese lots, own mixed No-Field, captured replacement, Ecaz/Duke assignment and other pending module/rulings 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.', ); return; }