diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 4dee92ff..9925cdca 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -29,6 +29,25 @@ optional skill bands, combinations and public expansion starts remain open. Rules implementation continues; comprehensive assurance and administration remain deferred until all rules are implemented. +**Classic World cards with phase modules — 16 October.** Original independent36 +Basic/Advanced Homeworlds now retains unused Tech3+ and Advanced Strongholds2+. +Native sources/rates, own-World H prohibition and existing provisional fee/rate +timing stay. No expansion Worlds, Skills, Nexus, Discovery or other overlay opens. +Real typed battles produce the Revival stock; actual Mentat earns held cards. +Three390px journeys/17 actions prove paid-return reopening, Fedaykin threshold, +one delayed Axlotl payment, bank2/personal1 support, exact split fee/typed return +and native-only Traitors. Revival reaches actual Guild timing, Arrakeen preserves +the next genuine Junction conflict, and the visitor battle reaches Collection. +Four policies finish24 useful World Revivals/156 actions and24 native battle/ +aftercare programmes/48 continuations with7609 validated R-or-H proposals. +Conserved card/closed-deck staging and controlled original first-Storm entropy +remain explicit; no force/wallet/owner/phase/history assigned. Parent corrected +the contributed fixture’s board-sector versus combat-territory API mismatch. +Frozen related20-file union passes192/192, and final12 new cases pass after +fixture projection/generic-entropy typing fixes. Types/lint, build and actual +prototype help pass; existing build warnings remain. No complete-mode, +calibration, broad assurance or deployed acceptance is claimed. + **Native Stone/Mirror with phase modules — 16 October.** Selected Ix/CHOAM retains original Tech/Advanced Strongholds in ordinary47 or independent50 setups; mixed E3 with these modules and Homeworlds/Skills/Nexus/Discovery/ diff --git a/docs/ECAZ_TREACHERY_RULES.md b/docs/ECAZ_TREACHERY_RULES.md index f1858955..5fde09e6 100644 --- a/docs/ECAZ_TREACHERY_RULES.md +++ b/docs/ECAZ_TREACHERY_RULES.md @@ -158,10 +158,10 @@ the original Richese cache remains separate. Use original setup and native ownership: remaining Tech after first Storm, Stronghold Cards only at actual end-Mentat. No owned module receipt, bank spice, -forces, phase or history is injected. This does not compose Homeworlds, Nexus, -Skills, Discovery, Sandtrout, mixed E3 or unrelated previews, convert played -games, or open public starts. Existing Stone/Mirror profile and allocation -guards are not widened. +forces, phase or history is injected. Classic Homeworlds separately compose the +same original phase modules below. Expansion Worlds, Nexus, Skills, Discovery, +Sandtrout, mixed E3 and unrelated previews, played-game conversion and public +starts stay gated. Stone/Mirror retains its separate selected-family contract. Recruits changes current free rates/ordinary allowance, not the original Axlotl once-per-phase trigger or delayed income. Eligible actual free revival triggers @@ -191,3 +191,34 @@ payment. Four policies complete20 battles/52 continuations/9564 R-or-H proposals two-token selections. No Face Dance route, new elite revival, complete mode, calibration, broad assurance or deployed acceptance is claimed. +## Classic World phase-module composition — 16 October 2026 + +The fresh classic independent36-card Homeworld entry now retains original unused +Tech3–6 Basic/Advanced and/or Strongholds2–6 Advanced; both requires Advanced3–6. +This composes the existing native-pool and phase-module handlers, not new printed +cost/rate arithmetic or a new public start. Expansion Worlds, Skills, Nexus, +Discovery, Sandtrout and unrelated overlays stay separate. + +Original setup creates native typed custody; first Storm assigns remaining Tech +and actual end-Mentat earns held cards. Axlotl/Heighliners retain once-per-phase +triggers and delayed income. A reserve fee or Harass deposit is not a free +revival/shipment. Earned Arrakeen funds actual support only, not +2 R or personal +cash. Original reward/cleanup and native optional aftercare retain their queues. + +Keep the existing provisional current native rate on each separate accepted +Revival request, prior free/paid classification and special-force/total caps. +Keep exact normal-first source-bearing R fees, original traits/printed face, +own-World fee/dial disjointness and fee-before-Harass. Native typed returns go +through their original destinations; Harass remains forbidden on every own +World, including both Emperor Worlds. No visitor/ally/future return funds R. + +Bounded World proof: related20-file union192/192 and final12 focused cases pass; +types/lint, build and actual prototype help pass with existing build warnings. +Three verified390px journeys/17 actions exercise prior-paid reopening, one typed +Fremen return plus five normals, one delayed Axlotl payout, earned bank2/personal1 +support, exact split fee and typed visitor return. Original next Guild timing/ +second paid conflict/Collection remain usable. Four policies finish24 World +Revivals/156 actions and24 battle/native-aftercare programmes/48 continuations, +validating7609 R-or-H proposals. Useful policy card holders are explicit conserved +staging. These are not complete modes, calibration, broad assurance or deployment. + diff --git a/docs/HARASS_WITHDRAW_RUNTIME.md b/docs/HARASS_WITHDRAW_RUNTIME.md index d75f2d22..bf40d466 100644 --- a/docs/HARASS_WITHDRAW_RUNTIME.md +++ b/docs/HARASS_WITHDRAW_RUNTIME.md @@ -143,8 +143,9 @@ game, D1, deployment or new recovery campaign changed. Full acceptance remains. Fresh `--profile ecaz-treachery --homeworlds` selects the independent three physical cards before original classic2–6 Basic/Advanced setup: base33+3. Harass composes on Arrakis and foreign Homeworlds. The printed own-World -prohibition remains, including both Advanced Emperor Worlds. Expansion -rosters, other overlays, public starts and played-game conversion stay closed. +prohibition remains, including both Advanced Emperor Worlds. Original +Tech/Advanced Strongholds now follow the [classic World phase contract](ECAZ_TREACHERY_RULES.md#classic-world-phase-module-composition--16-october-2026). +Expansion rosters, other overlays, public starts and save conversion stay closed. Recruits has a separate [native current-rate composition](RECRUITS_RUNTIME.md#classic-homeworld-composition--16-october-2026); Reinforcements now uses an @@ -222,8 +223,8 @@ route warnings remain; no HTTP or database boundary changed. after reveal. Unique returns are automatic; previously valid legacy battles retain unique-only derivation. See the [allocation contract](HARASS_ALLOCATION.md). - Clean classic Homeworlds use the [native reserve-routing contract](#classic-homeworld-reserve-routing--16-october-2026). - Without Homeworlds, clean classic/selected Ix/CHOAM rosters may separately retain - [original Tech3+ and Advanced Strongholds2+](ECAZ_TREACHERY_RULES.md#original-phase-module-composition--16-october-2026). + Classic World rosters or selected Ix/CHOAM without Worlds may retain + [original Tech3+ and Advanced Strongholds2+](ECAZ_TREACHERY_RULES.md#classic-world-phase-module-composition--16-october-2026). Nexus, Skills, Discoveries, broader World/faction composition and full-mode acceptance remain guarded. - Richese card-family configurations now admit Harass under the diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 46335608..25e2dabe 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,54 @@ # Dune implementation status +## 16 October 2026 — Classic World independent cards with phase modules + +Real fresh classic World+Tech/Stronghold lobbies rejected the blanket profile +gate before setup. Reuse original independent36-card Basic/Advanced Homeworld +setup with unused Tech3+ and/or Advanced Strongholds2+. Classic-only Worlds +remain; no expansion Worlds, Skills, Nexus, Discovery or unrelated overlay. +Keep exact sources, native custody/rates, printed own-World H ban, original +traits/face, fee/dial disjointness and inherited provisional fee/rate timing. + +Two genuine programmes use original typed shipments, battles, actual end-Mentat +and native current-rate/aftercare. First-Storm entropy and unopened non-worm +cards are explicitly controlled; later Storm entropy stays original. Physical +card positioning is conserved staging, not a natural purchase/trade. No force, +wallet, module owner, phase, threshold or receipt/history assignment. + +Parent reviewed both contributions, removed fixture/DTO/log wording assertions +and an unsupported causal Guild-wallet claim. Correct the contributed +`combatArmy` calls to use actual battle territory, not the Arrakeen sector key. +Preserve real eight-scenario battle smoke, including source/own-World checks, +and three Revival programmes. Twelve focused cases retain physical outcomes +and economics rather than echoes of fixture action logs. + +Three actual390px journeys/17 actions: Atreides pays2 for prior three returns, +readies, then takes two additional free after R without refund; Fremen's one +Fedaykin crosses high and leaves five further free, finishing6/reserves8/Tanks12. +Axlotl stays2 on Emperor while wallet7 is unchanged, then pays2 only at phase end. +Arrakeen14/Caladan6 pays R3 before H11, ends reserves14/Tanks6/personal7 after +bank2/personal1 support; original Heighliners transfers after settled income. +An actual second paid Junction conflict stays available, not silently consumed. +Visitor UI seals fee1K/2S, chooses committed1N1E/return2N3E, ends K11N/S4E, +reserves15/Tanks5 including1E, with native Guild alone getting Traitor controls. + +The browser harness reset viewport after a failed QA text wait; report to tool +QA, discard that mixed-viewport record and replace it with verified390px proof. +All three accepted surfaces have no horizontal overflow and no app errors. +Owned hosts/tabs/scaffolds removed; `/tmp/dune-world-phase-*-proof.json`. + +Four policies complete24 useful World Revival programmes/156 actions with +explicitly conserved useful R holders, plus24 normal battle/native-aftercare +programmes/48 continuations and7609 valid R-or-H proposals. Actual Caladan +one-counter deployment crosses native6→5 without a second shipment/payment. +This is bounded legal participation, not strategy/calibration or full mode, +assurance, persistence or deployed acceptance. Actual rooms/DBs untouched. +Frozen related20-file union passes192/192; final12 new cases pass after +correcting nullable World-table projections and preserving the native Crypto +method’s generic return type through binding. Types/lint, build and actual +prototype help pass. Existing Node deprecation, large-chunk and dynamic-route +classification warnings remain. No HTTP/database boundary or assurance campaign. + ## 16 October 2026 — Native Stone/Mirror with original phase modules Genuine cache/earned-Habbanya programmes reproduced the blanket Ix Tech/ diff --git a/docs/RECRUITS_RUNTIME.md b/docs/RECRUITS_RUNTIME.md index b81a651d..b0774dc2 100644 --- a/docs/RECRUITS_RUNTIME.md +++ b/docs/RECRUITS_RUNTIME.md @@ -36,7 +36,9 @@ Reuse native current free rates and typed revival deposits. No rate snapshot, new saved field, seven extra counters, automatic refund, generic module activation or played-save conversion is added. Reinforcements has a separate [native-source fee composition](REINFORCEMENTS_RUNTIME.md#classic-homeworld-reserve-fees--16-october-2026); -other optional overlays and expansion rosters stay outside this clean entry. +original Tech/Advanced Strongholds now follow the separate +[classic World phase contract](ECAZ_TREACHERY_RULES.md#classic-world-phase-module-composition--16-october-2026). +Expansion rosters and other overlays stay outside this entry. Publisher Ecaz & Moritani printed pp11/16 specifically gives low Fremen4, doubled but capped at7. Under the standing choose-recommended instruction, diff --git a/docs/REINFORCEMENTS_RUNTIME.md b/docs/REINFORCEMENTS_RUNTIME.md index d129cf9b..316b56c7 100644 --- a/docs/REINFORCEMENTS_RUNTIME.md +++ b/docs/REINFORCEMENTS_RUNTIME.md @@ -196,8 +196,9 @@ artifacts stay outside Git. Fresh `ecaz-treachery --homeworlds` composes original classic2–6 Basic/Advanced and all three independent physical cards (base33+3). Reinforcements may be used on Arrakis, a foreign World **or an own World**; it does not inherit -Harass's printed own-World ban. Expansion rosters, other overlays, public -starts and played-save conversion remain outside this entry. +Harass's printed own-World ban. Original Tech/Advanced Strongholds now follow +the [classic World phase contract](ECAZ_TREACHERY_RULES.md#classic-world-phase-module-composition--16-october-2026). +Expansion rosters, other overlays, public starts and save conversion stay gated. ### Sources, physical pool and original quote diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 6c8a8bc0..2574498d 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -666,7 +666,8 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP | Recruits / classic Homeworlds | Publisher E3 pp11/16 gives low Fremen4 doubled but capped7. Under choose-recommended, compose original separate-request native population observations: current rate at acceptance, retained accepted group price/free classification, then later requests use changed population and recorded free usage. Bulk7 versus split1+5 is provisional timing, not a new FAQ/user answer. Fremen still cannot buy a seventh without Tleilaxu; typed native deposits and original special-force caps remain. Prior paid/ready returns retain payment and opportunity while phase4 stays open. Clean classic2–6 Basic/Advanced only; Reinforcements uses its separate native fee contract and other roster/overlay/public gates stay. | [Current-rate scope and proof](RECRUITS_RUNTIME.md#classic-homeworld-composition--16-october-2026) | | Reinforcements / classic Homeworlds | Under standing choose-recommended, seal exact native normal-first sources for the existing three-reserve/all-outcome fee. Ambiguous omission rejects; a unique source may derive. Native reserves on an own World are also its army, so that fee cannot fund the same physical dial/casualties; an off-World fee leaves the pool. Keep original pre-fee traits/printed face, including explosion loss choices across a threshold, then typed Tanks, Harass return and original casualties. This is explicit provisional physical-pool composition, not a new FAQ or own-World ban. Source-aware full/complying plan reviews and named independent-card conditions use canonical names and actual combat locations. Other rosters/overlays/public starts remain gated. | [Native sources and exact proof](REINFORCEMENTS_RUNTIME.md#classic-homeworld-reserve-fees--16-october-2026) | | Reinforcements / clean selected Ix and CHOAM | Admit original independent cards with clean selected native/base Basic/Advanced rosters and no optional modules/overlays. Reuse existing provisional normal-first/all-outcome payment, original No-Field materialization before the fee, then successful Harass return; physical Stone ignores +2. Native cyborg substitution uses its own casualty origins, not a duplicate reserve fee. This closes admission consistency, not a new numerical ruling or public expansion gate. | [Profile and bounded proof](REINFORCEMENTS_RUNTIME.md#clean-selected-ixchoam-families--16-october-2026) | -| Independent Treachery / original phase modules | Printed E3 shared variant independence permits original unused Tech3–6 Basic/Advanced and/or Strongholds2–6 Advanced with clean classic/selected Ix/CHOAM native/base rosters. Keep original setup, actual phase triggers/delayed income and earned end-Mentat custody. Reuse provisional R fee-before-Harass; returned counters are not free Revival/shipment and +2 is not supported force. No Homeworlds, Skills, Nexus, Discovery, E3 mixture or unrelated preview; existing Stone/Mirror guards remain. | [Source and composition](ECAZ_TREACHERY_RULES.md#original-phase-module-composition--16-october-2026) | +| Independent Treachery / original phase modules | Printed E3 variant independence permits original unused Tech3–6 Basic/Advanced and/or Strongholds2–6 Advanced with clean classic/selected Ix/CHOAM native/base rosters. Classic Homeworlds now separately compose them; native E1/E2 remains without Worlds. Keep original setup, triggers/delayed income and earned end-Mentat custody. Reuse provisional R fee-before-Harass; returns are not free Revival/shipment and +2 is not supported force. Skills, Nexus, Discovery, E3 mixtures and unrelated previews stay gated. | [Phase composition](ECAZ_TREACHERY_RULES.md#original-phase-module-composition--16-october-2026), [classic World composition](ECAZ_TREACHERY_RULES.md#classic-world-phase-module-composition--16-october-2026) | +| Classic World independent cards / phase modules | Compose original unused Tech3+ and/or Advanced Strongholds2+ with the existing classic36-card Homeworld entry. Preserve native typed custody, current separate-request rates/paid ledgers, exact source-bearing R fees, original face/support, fee/dial disjointness and fee-before-Harass. Keep every own-World Harass ban; native returns are not shipment/free Revival. Original delayed income and earned cards add no personal subsidy or supported +2. This inherits existing provisional timing, not a new publisher ruling or native expansion/public gate. | [Source-bound contract](ECAZ_TREACHERY_RULES.md#classic-world-phase-module-composition--16-october-2026) | | Clean Ix Stone / Tooth / Mirror | Reuse existing provisional original Stone-first/uncopied-Tooth order; the earlier priority question stays unanswered. Printed Mirror copy-first and user-selected physical retention remain independent. Selected Ix/CHOAM now retains original Tech3+ and Advanced Strongholds2+, original47/independent50 plus separate cache, with genuine earned custody. No numerical ruling changes. Typed winner ambiguity, mixed E3 with these phase modules and other overlays stay guarded; existing Ecaz/Moritani combinations remain Advanced-only. | [Source and profile distinction](MIRROR_WEAPON_ENGINE_AUDIT.md#clean-selected-ix-composition--16-october-2026), [phase-module proof](MIRROR_WEAPON_ENGINE_AUDIT.md#original-phase-module-composition--16-october-2026) | | 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) | | Ixian Technology on Richese lots | Superseded 8 October by the authorized supplied Advanced physical p34, which expressly excludes Richese auctions from Ixian Technology. Richese cache/Black Market lots now open directly; the once-per-round Technology use remains for ordinary lots. The temporary per-lot decline scaffolding and its tests were deleted. | [Auction source boundary](RICHESE_AUCTION_RULES.md#13-september-saved-per-lot-technology-decline--superseded-8-october) | diff --git a/docs/STRONGHOLD_CARDS.md b/docs/STRONGHOLD_CARDS.md index 87c84009..c3b6e72b 100644 --- a/docs/STRONGHOLD_CARDS.md +++ b/docs/STRONGHOLD_CARDS.md @@ -6,12 +6,14 @@ Historical development module checkpoint, 7 September 2026: the six printed effe The fresh independent-card entry retains original unused Advanced Strongholds at2–6 seats and optional Tech3–6, with clean classic/selected Ix/CHOAM native/base -rosters and no Homeworlds. Actual end-Mentat earns ownership; no card is assigned -by a battle fixture. Earned Arrakeen funds only actual support (bank2/personal1 +rosters. Classic Homeworlds now compose the same original phase modules; +selected native families remain without Worlds. Actual end-Mentat earns +ownership; no card is assigned by a fixture. Arrakeen funds actual support (bank2/personal1 for support3); native Ix earns Tabr before a real next-turn attack and mixed Reinforcements fee/Harass return. Original token rewards/held effects stay. -No Stone/Mirror profile guard, other overlay, public start or played-save -conversion is widened. See the [shared source-bound contract](ECAZ_TREACHERY_RULES.md#original-phase-module-composition--16-october-2026). +Stone/Mirror uses its separate selected-family contract. Other overlays, public +starts and save conversion remain gated. See the [shared phase contract](ECAZ_TREACHERY_RULES.md#original-phase-module-composition--16-october-2026) +and [classic World composition](ECAZ_TREACHERY_RULES.md#classic-world-phase-module-composition--16-october-2026). ## Semuta composition — 11 October 2026 diff --git a/game/card-presentation.ts b/game/card-presentation.ts index 7e6866a8..4615afee 100644 --- a/game/card-presentation.ts +++ b/game/card-presentation.ts @@ -118,10 +118,10 @@ export function cardPresentation(card: VisibleCard): CardPresentation { guidance: ecaz.summary, gameplay: ecaz.gameplay, availability: ecaz.card.effect === 'recruits' - ? 'Recruits has development controls during Revival, including clean classic Basic/Advanced Homeworlds. Separately, clean classic/Ix/CHOAM rosters support original Tech3+ and Advanced Strongholds2+ without Homeworlds. Double the current native free rate within the seven-force total; typed deposits, special-force caps and original once-per-phase Axlotl income stay. Recorded prior paid returns keep payment under the provisional no-refund policy; pending transactions, unknown ledgers, later Fremen grants and replay remain guarded.' + ? 'Recruits has development controls during Revival, including classic Basic/Advanced Homeworlds with original Tech3+ and Advanced Strongholds2+. Selected Ix/CHOAM composes those phase modules separately without Homeworlds. Double the current native free rate within the seven-force total; typed deposits, special-force caps and original once-per-phase Axlotl income stay. Prior paid returns keep payment under the provisional no-refund policy; pending transactions, unknown ledgers, later Fremen grants and replay remain guarded.' : ecaz.card.effect === 'harassWithdraw' - ? 'Harass & Withdraw has controls in either battle-card slot, with physical allocation after reveal. Clean classic/Ix/CHOAM rosters compose original Tech3+ and Advanced Strongholds2+ without Homeworlds. Separately, classic Homeworlds returns typed counters through native custody: Advanced Emperor normal to Kaitain/Sardaukar to Salusa; Basic to its primary World. Never use this card on an own Homeworld. Original commitment/support stays. Own No-Field provisionally materializes first without resetting history. Stone compares post-withdrawal counters provisionally; its own profile/allocation guards remain.' - : 'Reinforcements works in either slot in clean selected Ix/CHOAM Basic/Advanced native/base rosters, classic tables including Homeworlds, and selected Advanced Occupy. Clean classic/Ix/CHOAM separately supports original Tech3+ and Advanced Strongholds2+ without Homeworlds. Spend three original own reserves normal-first for +2 normal score, not support or physical tokens. World sources seal when ambiguous; a fee here cannot also fund its dial. Original face/support stays. No-Field must leave three before payment; future Harass cannot fund it. Stone ignores +2 but charges reserves; its own profile guards stay. Cost/order interpretations remain provisional.', + ? 'Harass & Withdraw has controls in either battle-card slot, with physical allocation after reveal. Classic tables including Homeworlds compose original Tech3+ and Advanced Strongholds2+; selected Ix/CHOAM remains without Worlds. Native custody returns Advanced Emperor normal to Kaitain/Sardaukar to Salusa; Basic to its primary World. Never use Harass on an own Homeworld. Original commitment/support stays. Own No-Field provisionally materializes first without resetting history. Stone compares post-withdrawal counters provisionally; its own profile/allocation guards remain.' + : 'Reinforcements works in either slot in selected Ix/CHOAM, classic tables including Homeworlds, and selected Advanced Occupy. Classic Worlds now retain original Tech3+ and Advanced Strongholds2+; selected Ix/CHOAM phase modules remain without Worlds. Spend three original own reserves normal-first for +2 normal score, not support or physical tokens. Exact World sources seal when ambiguous; a fee here cannot also fund its dial. Original face/support stays. No-Field must leave three before payment; future Harass cannot fund it. Stone ignores +2 but charges reserves. Cost/order interpretations and remaining profile guards stay.', topics: RULE_TOPICS.filter((topic) => topic.id === (ecaz.card.effect === 'harassWithdraw' ? 'card-harass-withdraw' : ecaz.card.effect === 'reinforcements' ? 'card-reinforcements' : 'card-recruits')), }; const topicIds = diff --git a/game/engine.ts b/game/engine.ts index ef4cb342..eae268bb 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -10701,7 +10701,8 @@ function classicEcazHomeworldProfile(g: Game): boolean { return !!g.homeworlds && g.ecazTreachery === true && g.expansions.length === 0 && g.players.length >= 2 && g.players.length <= 6 && g.players.every(p => CLASSIC_FACTIONS[p.faction]) && - !g.nexusCards && !g.leaderSkills && !g.techTokens && !g.strongholdCards && + !g.nexusCards && !g.leaderSkills && + ((!g.techTokens && !g.strongholdCards) || independentCardModulesProfile(g)) && !g.discoveryEnabled && !g.discoveries && !g.discoveryStash && !g.greatMaker && !g.sandtrout; } /** Independent cards compose original phase modules, not unrelated previews. */ @@ -10711,7 +10712,7 @@ function independentCardModulesProfile(g: Game): boolean { (g.strongholdCards && !g.advanced) || g.expansions.length > 2 || (g.expansions.length === 2 && g.expansions[0] === g.expansions[1]) || g.expansions.some(expansion => expansion !== 'ix' && expansion !== 'choam') || - g.homeworlds || g.nexusCards || g.leaderSkills || + (g.homeworlds && g.expansions.length !== 0) || g.nexusCards || g.leaderSkills || g.discoveryEnabled || g.discoveries || g.discoveryStash || g.greatMaker || g.sandtrout || g.ecazOccupyPreview || g.semutaPreview || g.kullPreview || g.nexusKullPreview || g.richeseBetrayalPreview || g.guildBetrayalPreview || g.moritaniAssassinatePreview || @@ -10729,7 +10730,7 @@ export function initializeEcazTreacheryGameForAudit(state: Game): Game { g.ecazTreachery = true; const homeworlds = !!g.homeworlds; requireRule(!homeworlds || classicEcazHomeworldProfile(g), - 'Independent cards with Homeworlds require a fresh classic roster without other modules.'); + 'Independent cards with Homeworlds require a fresh classic roster with only supported original Tech or Advanced Strongholds.'); return initializeSetupGameForAudit(g, homeworlds, false, false, false, false, false, true, true); } function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = false, ix = false, discovery = false, leaderSkills = false, choam = false, factions = false, ecazTreachery = false, strongholdFactions = false): Game { diff --git a/game/reference.ts b/game/reference.ts index 602b7f34..1a30eb3f 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -3417,7 +3417,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Sietch Tabr uses the opposing dial, excluding leaders and other bonuses. A single traitor victory qualifies; mutual destruction does not. Tuek pays after an ordinary loss or a single traitor outcome as well as a win; a double traitor gives neither faction spice. Habbanya also decides a tied Stone Burner comparison.', 'Fresh Advanced local development tables may combine all fourteen Leader Skills with Stronghold Cards in classic or supported Ixian, Tleilaxu, CHOAM and Richese skill rosters, two through six players; optional Tech Tokens needs three through six. Original deck/cache and end-Mentat claims remain. Arrakeen pays bank support, not personal income; only the remaining actual payer debit qualifies separate normal Banker income. Skill rescue preserves the original support charge, then card cleanup and Tech reward finish before Face Dance. Modified/copied Diplomat defenses and active skill/Stone interpretations stay guarded; public activation, E3/other overlays and save conversion remain separate.', 'The original standalone Ecaz or Moritani skill roster additionally supports these Advanced combinations with the exact ecaz deck, original five-disc training and native exclusions. Normal Warmaster one on a temporary Duke composes Carthag Shield without granting trained three; normal winner Suk precedes unused-disc Moritani assassination, printed bounty, retained winner Tech and one private Mentat replacement. E3 pairs, mixtures and allied Occupy skills stay separate.', - 'Fresh independent Treachery previews separately compose clean classic or selected Ix/CHOAM native/base rosters with Advanced Strongholds2–6 and optional Tech3–6, without Homeworlds. Actual end-Mentat earns custody. Reinforcements adds no supported counters; Harass vacates control without transferring a retained card early. Original card effects, costs and winner Tech order remain, while Stone/Mirror profile guards and unrelated modules stay.', + 'Fresh independent Treachery previews compose classic rosters including original Homeworlds, or selected Ix/CHOAM native/base rosters without Worlds, with Advanced Strongholds2–6 and optional Tech3–6. Actual end-Mentat earns custody. Reinforcements adds no supported counters; Harass vacates control without transferring a retained card early. Exact native fee/return, original card effects and winner Tech order remain; other overlays stay guarded.', 'The six effects, public ownership and pre-plan choice are integrated in development games. Enable Stronghold Cards in an Advanced preview lobby. Complete Advanced compliance and every expansion interaction remain under verification.', ], example: @@ -3525,7 +3525,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Owning all three tokens counts as one additional stronghold at victory checks. An alliance cannot combine separate token holdings to qualify: one member must own the complete set. Multiple sides reaching the victory target can share the win.', 'Token ownership and pending income are public and persist with the room. Fresh supported classic, E1/E2 native or standalone Ecaz/Moritani Leader Skills tables retain original three-through-six-seat Basic/Advanced setup and effects; Advanced may also retain Stronghold Cards. Skill rescue or board-spice awards finish before the mandatory original winner token reward and later Face Dance. Banker remains a separate source-to-Mentat opt-in. CHOAM’s separate native opening payout is not ordinary poverty Charity. Two-player tokens, mixed E3/other combinations and full combined acceptance remain unfinished.', 'Without Skills, fresh native E1/E2 families or standalone Ecaz OR Moritani with classic opponents may preserve unused original Tech Tokens through the local factions entry, Basic/Advanced three through six players; Advanced stronghold-factions adds the original six Stronghold Cards. Native payments, typed casualties, selected Ecaz lead rewards and Moritani original assassination remain separate. Split allied holdings never qualify as a complete set. Basic odd Occupy, Richese cache/No-Field rulings, unrelated overlays, public starts and saved-game conversion remain guarded.', - 'Clean classic or selected Ix/CHOAM native/base independent-card previews retain unused Tech3–6 Basic/Advanced, optionally Advanced Strongholds2–6. Recruits changes free rates without repeating Axlotl or advancing its payout. Harass deposits and reserve fees are not free Revival or off-planet shipment. Original phase income and winner custody stay; Homeworlds and other overlays remain separate.', + 'Classic independent-card previews including Homeworlds, or selected Ix/CHOAM native/base rosters without Worlds, retain unused Tech3–6 Basic/Advanced and optional Advanced Strongholds2–6. Recruits changes current native rates without repeating Axlotl or advancing payout. Harass deposits and reserve fees are not free Revival or off-planet shipment. Original phase income/native custody/winner rewards remain; other overlays stay separate.', ], example: 'You own Heighliners and Axlotl Tanks. When Emperor ships from reserves, place two spice on Heighliners. A later shipment adds nothing. Collect the two spice when Shipping and Movement ends.', @@ -4982,7 +4982,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'Atreides rises from two free forces to four; Fremen from three to six; Guild from one to two. The ordinary force limit becomes seven. Native unlimited CHOAM and Tleilaxu allowances remain unlimited, and their free rates become zero and four. Paid prices and separate elite limits still apply.', 'Free prevention still prevents free returns. Prior paid ordinary returns now have a visibly provisional policy: preserve payment, deduct only recorded free returns from the doubled free allowance and all returns from the total cap. Two free plus one paid Atreides may take two more free without refund; this is not a numerical user ruling. Missing/inconsistent ledgers, later Fremen grants, pending transactions and same-turn replay remain guarded.', 'Clean classic2–6 Basic/Advanced Homeworlds compose through the fresh independent36-card setup. Double current native free rates including low-population bonuses, within the seven-force total. Provisionally recheck population and recorded free use on each separate request: low Fremen may take7 together, but1 crossing high leaves5 further free and cannot buy a seventh without Tleilaxu. Original typed destinations and special-force limits stay. Other overlays/rosters, public activation and whole-mode acceptance remain unfinished.', - 'Without Homeworlds, clean classic or selected Ix/CHOAM native/base rosters may retain original unused Tech Tokens at3–6 and/or Stronghold Cards at2–6 Advanced. Setup, first-Storm Tech assignment and actual end-Mentat Stronghold custody remain original. Recruits changes free rates, not the once-per-phase Axlotl trigger or delayed payout; Harass returns are not free Revival.', + 'Classic rosters including Homeworlds, or selected Ix/CHOAM native/base rosters without Worlds, may retain original unused Tech Tokens3–6 and/or Stronghold Cards2–6 Advanced. Setup, first-Storm Tech and actual end-Mentat custody remain original. Recruits changes current native free rates, not the once-per-phase Axlotl trigger/delayed payout. Harass returns are not free Revival.', ], related: ['revival', 'homeworlds', 'ecaz-modules', 'expansion-faction-games', 'implementation-checklist'], checklist: [ @@ -4990,7 +4990,7 @@ export const RULE_TOPICS: RuleTopic[] = [ { area: 'Player controls', status: 'Implemented', detail: 'The owned physical card has full inspection, a Revival action and explicit unavailable reasons. All seats see the effective public rates after play.', evidence: ['components/recruits.tsx', 'components/game-table.tsx', 'game/card-presentation.ts'] }, { area: 'AI', status: 'Partial', detail: 'All four profiles use the same legal owned offer for useful own or allied revival, then existing authoritative revival choices. Complete card strategy and calibration remain unfinished.', evidence: ['game/bots.ts'] }, { area: 'Documentation', status: 'Implemented', detail: 'The guide records examples, limits, independent-cap inference and the visibly provisional late-paid policy; source alternatives and late-rate questions remain explicit.', evidence: ['docs/RECRUITS_RULES.md', 'docs/RECRUITS_RUNTIME.md'] }, - { area: 'Verification', status: 'Partial', detail: 'Earlier World proof: ten native cases, four mobile journeys and16 four-policy programmes/104 actions cover rates, typed caps, prior paid returns and destinations. Separate original phase-module proof adds genuine battle-filled Tanks, two mobile Revival journeys and16 useful four-policy programmes/120 actions: doubled rates, paid remainder and one delayed Axlotl payout after two free revivals. No new special-counter revival or complete-mode/calibration claim.', evidence: ['tests/recruits-modules.test.ts', 'tests/fixture-recruits-modules.ts', 'tests/fixture-independent-card-modules.ts', 'tests/recruits-homeworld.test.ts', 'tests/fixture-recruits-homeworld.ts', 'tests/recruits.test.ts', 'tests/recruits-engine.test.ts', 'tests/recruits-controls.test.tsx', 'tests/recruits-recovery.test.ts', 'tests/prototype-room.test.ts'] }, + { area: 'Verification', status: 'Partial', detail: 'Native rates/caps, paid/free ledgers, useful Recruits and one delayed Axlotl payment compose with original phase modules and World custody. Classic World batch: one mobile journey/9 actions,24 four-policy programmes/156 actions with explicitly conserved useful card holders. Bulk7 versus split6 and one Fedaykin plus five normals retain current thresholds. Earlier World/non-World proof stays dated in its contracts; complete modes/calibration remain open.', evidence: ['tests/world-phase-recruits.test.ts', 'tests/fixture-world-phase-recruits.ts', 'tests/recruits-modules.test.ts', 'tests/fixture-recruits-modules.ts', 'tests/fixture-independent-card-modules.ts', 'tests/recruits-homeworld.test.ts', 'tests/fixture-recruits-homeworld.ts', 'tests/recruits.test.ts', 'tests/recruits-engine.test.ts', 'tests/recruits-controls.test.tsx', 'tests/recruits-recovery.test.ts', 'tests/prototype-room.test.ts'] }, ], }, { @@ -5006,7 +5006,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'The normal-outcome score gains 2 without adding to the physical dial, spice support or on-board casualties. The card and its reserve cost are consumed even on a Traitor victory, a mutual Traitor result or a Lasgun/Shield explosion; successful Traitor calls still decide the outcome directly.', 'All-outcome payment, normal-first selection and reserve transfer counting as battle losses remain provisional interpretations, not publisher rulings. Same-plan Harass pays the original three-reserve cost before returns; returned reserves cannot fund or change it. In native Advanced Occupy, original or copied Stone ignores +2 normal score but still charges the reserve cost. Basic/unprofiled co-side and other profile/module limits remain guarded.', 'With your own concealed No-Field, the provisional native order materializes its reserve-limited counters before this cost. At least three own reserves must remain after that reveal, before any future Harass return; otherwise Reinforcements is unavailable before the first plan seals. This checks only the actual card user, never its ally’s marker or reserves.', - 'Clean classic2–6 Basic/Advanced Homeworlds use exact native normal-first fee sources, sealed in the plan. Ambiguous omissions reject; unique sources may derive. An own-World fee cannot also fund physical dial/casualties, but original printed face/support remains. Original high-face explosion losses survive a fee crossing low. Native-only Traitors, typed Tanks and cost-before-Harass remain; this is explicit provisional composition, not a new card FAQ.', + 'Classic2–6 Basic/Advanced Homeworlds retain exact native normal-first fee sources, sealed when ambiguous; unique sources may derive. An own-World fee cannot also fund dial/casualties, but original printed face/support remains, including explosion losses across a threshold. Original Tech3+ and Advanced Strongholds2+ now compose without changing that quote, native-only Traitors, typed Tanks or cost-before-Harass. This remains provisional composition, not a new card FAQ.', 'One canonical physical-name registry now includes the independent cards for current facts and named battle conditions. Naming Reinforcements in a weapon slot does not make it a weapon category. Native questions use actual combat-location labels. Source-aware legal completion, private full-plan/read-only review and the fill button retain exact native sources.', 'Clean selected Ix and/or CHOAM families retain original Basic/Advanced setup and independent cards. Native cyborg substitution and Face Dance use their original physical pools, not a second copy of the reserve fee. Richese No-Field reveal precedes payment; physical Stone ignores +2. Separately retain original Tech3+ and/or Advanced Strongholds2+ without Homeworlds, using actual phase income/earned custody; other family/module and Stone profile guards stay.', ], @@ -5016,7 +5016,7 @@ export const RULE_TOPICS: RuleTopic[] = [ { area: 'Player controls', status: 'Partial', detail: 'Owner-only cost/source choices, own-World physical bounds, wrapped exact allocation, source-aware legal-plan fill and earned private reviews connect; category answers remain None. Native named-question preview/answer/history uses canonical combat labels.', evidence: ['components/battle-preparation.tsx', 'components/reinforcements-sources.tsx', 'components/private-battle-plan.tsx', 'components/battle-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx'] }, { area: 'AI', status: 'Partial', detail: 'Four profiles consider legal physical slot candidates and the normal score bonus; strategy and calibration remain unfinished.', evidence: ['game/bots.ts'] }, { area: 'Documentation', status: 'Partial', detail: 'The printed effect and unresolved Traitor, explosion, type-selection and accounting interpretations are distinguished from this temporary prototype.', evidence: ['docs/ECAZ_TREACHERY_RULES.md', 'docs/REINFORCEMENTS_RUNTIME.md'] }, - { area: 'Verification', status: 'Partial', detail: 'Native setup/shipment covers typed fees, deficient funding, exact World sources, disjointness, frozen face/explosion, Traitors, Harass and named promises. Earlier World proof: six mobile/32 programmes/114 continuations/1682 R proposals. Clean families: seven mobile/48 programmes/194 continuations/3022 proposals. Phase modules: two mobile battles/20 programmes/52 continuations/9564 R-or-H proposals with earned bank support, Tabr and Tech rewards. Bounded legal proof, not complete modes or calibration.', evidence: ['tests/battle-card-modules.test.ts', 'tests/fixture-battle-card-modules.ts', 'tests/fixture-independent-card-modules.ts', 'tests/native-family-reinforcements.test.ts', 'tests/fixture-native-family-reinforcements.ts', 'tests/richese-reinforcements.test.ts', 'tests/fixture-richese-reinforcements.ts', 'tests/reinforcements-homeworld.test.ts', 'tests/fixture-reinforcements-homeworld.ts', 'tests/reinforcements-homeworld-arrakis.test.ts', 'tests/fixture-reinforcements-homeworld-arrakis.ts', 'tests/no-field-reinforcements.test.ts', 'tests/fixture-no-field-reinforcements.ts', 'tests/stone-reinforcements.test.ts', 'tests/harass-reinforcements.test.ts', 'tests/reinforcements.test.ts', 'tests/reinforcements-engine.test.ts', 'tests/ecaz-occupy-cards-runtime.test.ts', 'docs/ECAZ_OCCUPY_RULES.md'] }, + { area: 'Verification', status: 'Partial', detail: 'Earlier World/family/phase evidence stays dated in linked contracts. Classic World phase batch adds earned bank support, split native fee/typed return, own-World disjointness/Harass ban and actual Caladan continuation. Two mobile battle journeys/8 actions;24 four-policy battle/aftercare programmes/48 continuations and7609 validated R-or-H proposals preserve normal outcomes. Original sources, traits and physical counters remain; no complete-mode/calibration/deployed claim.', evidence: ['tests/world-phase-battle.test.ts', 'tests/fixture-world-phase-battle.ts', 'tests/battle-card-modules.test.ts', 'tests/fixture-battle-card-modules.ts', 'tests/fixture-independent-card-modules.ts', 'tests/native-family-reinforcements.test.ts', 'tests/fixture-native-family-reinforcements.ts', 'tests/richese-reinforcements.test.ts', 'tests/fixture-richese-reinforcements.ts', 'tests/reinforcements-homeworld.test.ts', 'tests/fixture-reinforcements-homeworld.ts', 'tests/reinforcements-homeworld-arrakis.test.ts', 'tests/fixture-reinforcements-homeworld-arrakis.ts', 'tests/no-field-reinforcements.test.ts', 'tests/fixture-no-field-reinforcements.ts', 'tests/stone-reinforcements.test.ts', 'tests/harass-reinforcements.test.ts', 'tests/reinforcements.test.ts', 'tests/reinforcements-engine.test.ts', 'tests/ecaz-occupy-cards-runtime.test.ts', 'docs/ECAZ_OCCUPY_RULES.md'] }, ], }, { @@ -5036,7 +5036,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'In supported same-plan Reinforcements, the provisional order pays three original own reserves before Harass returns undialed counters. Future returns cannot fund or change that reserve quote; neither the +2 score bonus nor that cost adds to the physical commitment. Both cards discard after use. An opposing successful Traitor cancels Harass but not the provisional Reinforcements cost.', 'With an own concealed No-Field, this prototype provisionally uses the original materialization-first order. Before sealing, privately forecast its actual ordinary counters at the original marker sector; do not move them early. Both plans seal before native revelation, then any return allocation uses actual counters. Marker0 remains legal with a leader/cards, reserve shortage caps materialization, and withdrawal does not return/reset the token. Opposing successful Traitor cancels Harass, not that native reveal.', 'Clean classic2–6 Basic/Advanced Homeworlds may be selected before independent36-card setup. Harass works on Arrakis or a foreign Homeworld, never any own World. Return actual undialed board/visitor counters through native custody, not Tanks: Basic primary; Advanced Emperor normal to Kaitain/Sardaukar to Salusa. Original dial/support and committed counters remain fixed across thresholds. Native-only Traitors, late defense/explosion and disposal stay. Recruits has native current rates; Reinforcements seals exact original native fees before these returns. Other overlays/rosters remain guarded.', - 'Without Homeworlds, clean classic or selected Ix/CHOAM native/base rosters compose original Tech3+ and/or Advanced Strongholds2+. An earned held Stronghold still affects only its original eligible plan/actual support; +2 Reinforcements adds no supported counters. Harass deposits are not an off-planet shipment or free Revival. Original winner card cleanup/Tech reward precedes native Face Dance.', + 'Classic rosters including Homeworlds, or selected Ix/CHOAM native/base rosters without Worlds, compose original Tech3+ and/or Advanced Strongholds2+. An earned held Stronghold affects only its original eligible plan/actual support; +2 R adds no supported counters. Harass deposits are not off-planet shipment/free Revival. Original winner card cleanup/Tech reward and native queues retain their order.', ], related: ['battle-cards', 'battle', 'card-recruits', 'homeworlds', 'ecaz-modules', 'implementation-checklist'], checklist: [ @@ -5044,7 +5044,7 @@ export const RULE_TOPICS: RuleTopic[] = [ { area: 'Player controls', status: 'Partial', detail: 'Either slot and native typed allocation remain. World names, Basic versus Advanced reserve destinations and fixed-commitment timing are explicit. Invaders no longer receive forbidden Traitor controls. Four actual390px journeys complete Basic foreign loss, Advanced foreign victory, Salusa threshold return and own-Salusa ban/plain alternative into Collection.', evidence: ['components/battle-preparation.tsx', 'components/harass-withdraw.tsx', 'components/revealed-battle.tsx', 'components/game-table.tsx', 'components/harass-withdraw-choice.tsx'] }, { area: 'AI', status: 'Partial', detail: 'All four profiles use legal shared precommit quotes and exact after-reveal allocations without an invader vote. Sixteen native World programs accept36 continuation actions and validate original precommit proposals through normal/explosion/own-World alternatives. Retreat strategy and combined strength remain deferred.', evidence: ['game/bots.ts', 'tests/ecaz-battle-bots.test.ts'] }, { area: 'Documentation', status: 'Partial', detail: 'Printed own-World restriction and explicit native destination/fixed-commitment composition are separated from original materialization/Stone policies and remaining faction/module/final gates.', evidence: ['docs/HARASS_WITHDRAW_RUNTIME.md', 'docs/ECAZ_TREACHERY_RULES.md', 'docs/STONE_BURNER_ENGINE_AUDIT.md', 'docs/RICHESE_NO_FIELD_RULES.md'] }, - { area: 'Verification', status: 'Partial', detail: 'Earlier native World cases cover real typed invasion/Arrakis shipments, both slots/modes, selected commitment, reserve destinations, Traitors, disposal, explosion and thresholds; four mobile/16 policy programmes reach Collection. Separate phase-module proof adds earned Arrakeen/Tabr, typed fee-before-return and actual Tech awards: two mobile battles and20 four-policy programmes/52 continuations/9564 R-or-H proposals. No Face Dance route or complete-mode/deployed/calibration claim.', evidence: ['tests/battle-card-modules.test.ts', 'tests/fixture-battle-card-modules.ts', 'tests/fixture-independent-card-modules.ts', 'tests/harass-homeworld.test.ts', 'tests/fixture-harass-homeworld.ts', 'tests/harass-homeworld-arrakis.test.ts', 'tests/fixture-harass-homeworld-arrakis.ts', 'tests/harass-no-field.test.ts', 'tests/fixture-harass-no-field.ts', 'tests/no-field-reinforcements.test.ts', 'tests/stone-harass-quote.test.ts', 'tests/stone-harass-runtime.test.ts', 'tests/fixture-stone-harass.ts', 'tests/ecaz-occupy-harass-quote.test.ts', 'tests/harass-allocation-engine.test.ts', 'tests/harass-allocation-recovery.test.ts', 'tests/harass-withdraw-choice-controls.test.tsx', 'tests/harass-withdraw.test.ts', 'tests/ecaz-battle-controls.test.tsx', 'tests/ecaz-battle-bots.test.ts', 'tests/ecaz-battle-recovery.test.ts'] }, + { area: 'Verification', status: 'Partial', detail: 'Original typed return, native reserve destinations, frozen commitment, own-World ban and native-only Traitors remain. Classic World phase batch adds earned bank support, exact source fee before typed withdrawal and preserved original winner/Caladan queues. Two mobile battles/8 actions and24 four-policy battle/aftercare programmes/48 continuations/7609 R-or-H proposals reach usable native decisions. Earlier World/No-Field/Stone proof is separate; no complete-mode/deployed/calibration claim.', evidence: ['tests/world-phase-battle.test.ts', 'tests/fixture-world-phase-battle.ts', 'tests/battle-card-modules.test.ts', 'tests/fixture-battle-card-modules.ts', 'tests/fixture-independent-card-modules.ts', 'tests/harass-homeworld.test.ts', 'tests/fixture-harass-homeworld.ts', 'tests/harass-homeworld-arrakis.test.ts', 'tests/fixture-harass-homeworld-arrakis.ts', 'tests/harass-no-field.test.ts', 'tests/fixture-harass-no-field.ts', 'tests/no-field-reinforcements.test.ts', 'tests/stone-harass-quote.test.ts', 'tests/stone-harass-runtime.test.ts', 'tests/fixture-stone-harass.ts', 'tests/ecaz-occupy-harass-quote.test.ts', 'tests/harass-allocation-engine.test.ts', 'tests/harass-allocation-recovery.test.ts', 'tests/harass-withdraw-choice-controls.test.tsx', 'tests/harass-withdraw.test.ts', 'tests/ecaz-battle-controls.test.tsx', 'tests/ecaz-battle-bots.test.ts', 'tests/ecaz-battle-recovery.test.ts'] }, ], }, { diff --git a/tests/fixture-harass-homeworld.ts b/tests/fixture-harass-homeworld.ts index ad75be56..117ad4b3 100644 --- a/tests/fixture-harass-homeworld.ts +++ b/tests/fixture-harass-homeworld.ts @@ -117,8 +117,8 @@ function initialize(game: Game): Game { } } -/** Homeworlds selected before the independent 33+3 physical setup. No expansion - * roster, other optional module, preselected variant flag or synthetic phase. */ +/** Homeworlds selected before the independent 33+3 physical setup. Original + * Tech/Advanced Strongholds may be configured in a fresh classic initial lobby. */ export function createHarassHomeworldSetup(options: HarassHomeworldOptions = {}): HarassHomeworldSetup { const ids = options.seatIds ?? ['emperor', 'guild', 'atreides']; let game = options.initial ? structuredClone(options.initial) @@ -130,8 +130,8 @@ export function createHarassHomeworldSetup(options: HarassHomeworldOptions = {}) assert.equal(game.status, 'lobby'); assert.equal(game.advanced, options.advanced ?? game.advanced); assert.deepEqual(game.expansions, []); assert.deepEqual(game.players.map(p => p.faction).sort(), ['atreides', 'emperor', 'guild']); - assert.ok(!game.ecazTreachery && !game.nexusCards && !game.leaderSkills && !game.techTokens && - !game.strongholdCards && !game.discoveryEnabled && !game.discoveries && !game.greatMaker); + assert.ok(!game.ecazTreachery && !game.nexusCards && !game.leaderSkills && + !game.discoveryEnabled && !game.discoveries && !game.greatMaker); if (!game.homeworlds) game = applyAction(game, game.host, { type: 'homeworlds', enabled: true }); assert.deepEqual(game.homeworlds, { custody: null }); const nativeSteps: HarassHomeworldStep[] = []; diff --git a/tests/fixture-independent-card-modules.ts b/tests/fixture-independent-card-modules.ts index b9de2b03..1b6e2dfc 100644 --- a/tests/fixture-independent-card-modules.ts +++ b/tests/fixture-independent-card-modules.ts @@ -17,6 +17,7 @@ export type IndependentCardModulesOptions = { strongholds?: boolean; /** False keeps the original native deck for revealed-card comparisons. */ independent?: boolean; + homeworlds?: boolean; }; /** Select original modules and independent cards before the undealt native setup. @@ -28,6 +29,7 @@ export function createIndependentCardModulesFixture(options: IndependentCardModu let game = createGame('INDEPENDENTCARDMODULES', newPlayer(factions[0], factions[0], factions[0]), advanced, options.expansions ?? []); for (const faction of factions.slice(1)) joinGame(game, newPlayer(faction, faction, faction)); + if (options.homeworlds) game = applyAction(game, game.host, { type: 'homeworlds', enabled: true }); if (options.tech ?? true) game.techTokens = createTechTokens(); if (options.strongholds) game.strongholdCards = createStrongholdCards(); const steps: IndependentCardModulesStep[] = []; diff --git a/tests/fixture-world-phase-battle.ts b/tests/fixture-world-phase-battle.ts new file mode 100644 index 00000000..7d80a197 --- /dev/null +++ b/tests/fixture-world-phase-battle.ts @@ -0,0 +1,232 @@ +import assert from 'node:assert/strict'; +import { applyAction, viewGame, type Game } from '../game/engine'; +import { combatArmy } from '../game/combat-location'; +import { nativeShipmentSources } from '../game/homeworld-options'; +import { homeworldShipmentChoice } from '../game/homeworld-shipment-options'; +import type { NativeReserveSelections } from '../game/homeworld-native-reserves'; +import { REINFORCEMENTS_CARD } from '../game/reinforcements'; +import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw'; +import { + actHarassHomeworld, assertHarassHomeworldCustody, createHarassHomeworldSetup, + harassHomeworldClean, harassHomeworldPlayer, nextHarassHomeworldStep, + type HarassHomeworldStep, +} from './fixture-harass-homeworld'; +import { createIndependentCardModulesFixture } from './fixture-independent-card-modules'; +import { takeHarassCard } from './fixture-harass-withdraw'; +import { + createReinforcementsHomeworldFixture, type ReinforcementsHomeworldFixture, +} from './fixture-reinforcements-homeworld'; + +export type WorldPhaseBattleStep = HarassHomeworldStep; +export type WorldPhaseBattleOptions = { + initial?: Game; + advanced?: boolean; + tech?: boolean; + strongholds?: boolean; + support?: number; +}; +export type WorldPhaseBattleFixture = { + initial: Game; + afterSetup: Game; + beforeFirstMentat: Game; + firstMentatStep: WorldPhaseBattleStep; + afterFirstMentat: Game; + shipments: { before: Game; step: WorldPhaseBattleStep; after: Game }[]; + beforeBattle: Game; + battleStep: WorldPhaseBattleStep; + game: Game; + owner: string; + opponent: string; + observer: string; + location: string; + plans: [WorldPhaseBattleStep, WorldPhaseBattleStep]; + sources: NativeReserveSelections; + returns: Record; + actions: WorldPhaseBattleStep[]; + staging: string[]; +}; +export const worldPhaseBattlePlayer = harassHomeworldPlayer; +export const worldPhaseBattleClean = harassHomeworldClean; +export const assertWorldPhaseBattleInventory = assertHarassHomeworldCustody; + +/** Only the actual first Storm's native lottery/dials use fixed entropy. Later + * Storms keep their original native draws and movement semantics. */ +function firstStormAction(game: Game, step: WorldPhaseBattleStep): Game { + if (game.turn !== 1 || game.phase !== 0) return applyAction(game, step.actor, step.action); + const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); + crypto.getRandomValues = (array: T): T => { + assert.ok(array); new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255); return array; + }; + try { return applyAction(game, step.actor, step.action); } + finally { + if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); + else Reflect.deleteProperty(crypto, 'getRandomValues'); + } +} +export function actWorldPhaseBattle(game: Game, step: WorldPhaseBattleStep, + actions?: WorldPhaseBattleStep[]): Game { + actions?.push(structuredClone(step)); return firstStormAction(game, step); +} +export function advanceWorldPhaseBattle(game: Game, until: (state: Game) => boolean, + actions?: WorldPhaseBattleStep[]): Game { + for (let n = 0; n < 1800; n++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished'); + const step = nextHarassHomeworldStep(game); + assert.ok(step, 'Supply the genuine human plan or physical return allocation'); + game = actWorldPhaseBattle(game, step, actions); + } + throw Error('Original World/phase-module programme did not reach its requested boundary'); +} + +/** Atreides retains its original ten Arrakeen counters through the actual first + * END Mentat. Turn two: Guild pays for four native invaders, Atreides pays for + * four Caladan reinforcements, and Emperor pays to invade Junction with one. + * The latter real conflict keeps Battle open, separating support/Tech economics + * from later Collection. No force, wallet, phase, module owner or receipt staging. + * Existing provisional utility/Stronghold timing is inherited, not a new ruling. */ +export function createWorldPhaseBattleFixture(options: WorldPhaseBattleOptions = {}): WorldPhaseBattleFixture { + const advanced = options.advanced ?? options.initial?.advanced ?? true; + const base = options.initial ? createHarassHomeworldSetup({ advanced, initial: options.initial }) : null; + const original = base ? { initial: base.initial, game: base.afterSetup, steps: base.nativeSteps } + : createIndependentCardModulesFixture({ advanced, homeworlds: true, + tech: options.tech ?? true, strongholds: options.strongholds ?? advanced, + factions: ['emperor', 'guild', 'atreides'] }); + let game = structuredClone(original.game); + const actions = [...original.steps], initial = structuredClone(original.initial); + assert.ok(game.homeworlds?.custody); + if (options.tech !== undefined) assert.equal(!!game.techTokens, options.tech); + if (options.strongholds !== undefined) assert.equal(!!game.strongholdCards, options.strongholds); + const afterSetup = structuredClone(game); + const owner = game.players.find(p => p.faction === 'atreides')!.id; + const opponent = game.players.find(p => p.faction === 'guild')!.id; + const observer = game.players.find(p => p.faction === 'emperor')!.id; + const location = 'arrakeen:10'; + const staging = [ + 'Only original setup and first-Storm entropy are fixed; all later Storm draws and movement remain native.', + 'Position eight original unopened non-worm Spice Cards for the two actual native Spice Blows; no draw, threshold, force or phase assignment.', + ]; + for (let position = 0; position < 8; position++) { + const at = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery); + assert.ok(at >= position); game.spiceDeck.splice(position, 0, game.spiceDeck.splice(at, 1)[0]); + } + const act = (step: WorldPhaseBattleStep): Game => { + game = actWorldPhaseBattle(game, step, actions); return game; + }; + const reach = (until: (state: Game) => boolean) => { game = advanceWorldPhaseBattle(game, until, actions); }; + reach(g => g.phase === 8 && harassHomeworldClean(g)); + let beforeFirstMentat: Game | undefined, firstMentatStep: WorldPhaseBattleStep | undefined; + while (game.turn === 1) { + const step = nextHarassHomeworldStep(game); assert.ok(step); + const before = structuredClone(game), after = act(step); + if (after.turn === 2) { beforeFirstMentat = before; firstMentatStep = step; } + } + assert.ok(beforeFirstMentat && firstMentatStep); + const afterFirstMentat = structuredClone(game); + reach(g => g.turn === 2 && g.phase === 5 && harassHomeworldClean(g)); + const shipments: WorldPhaseBattleFixture['shipments'] = []; + while (game.phase === 5) { + const actor = game.active!; + if (harassHomeworldClean(game) && !harassHomeworldPlayer(game, actor).shipped) { + const amount = actor === observer ? 1 : 4; + const homeworldSources = nativeShipmentSources(viewGame(game, actor), amount, 0); + assert.ok(homeworldSources, 'Withdraw original typed native reserves, never guessed source pools'); + const before = structuredClone(game); + const invasion = actor === observer ? homeworldShipmentChoice(viewGame(game, actor), 'homeworld:guild', + Object.fromEntries(Object.entries(homeworldSources).filter(([, forces]) => forces.normal + forces.elite > 0))) : null; + if (invasion) assert.ok(invasion.action, invasion.blocked ?? 'Original typed invasion must be available'); + const step: WorldPhaseBattleStep = { actor, action: invasion?.action ?? { type: 'ship', + territory: 'arrakeen', sector: 10, amount, elite: 0, homeworldSources, allyPayment: 0 } }; + act(step); reach(harassHomeworldClean); + shipments.push({ before, step, after: structuredClone(game) }); + } else { + const step = nextHarassHomeworldStep(game); assert.ok(step); act(step); + } + } + reach(g => g.phase === 6 && harassHomeworldClean(g)); + for (const p of game.players) game.deck.push(...p.hand.splice(0)); + takeHarassCard(game, owner, REINFORCEMENTS_CARD); + takeHarassCard(game, owner, HARASS_WITHDRAW_CARD); + staging.push('At the real Battle window, conserve all original hands into undealt stock and position the original Reinforcements and Harass & Withdraw in Atreides’ hand. This is explicit physical card staging, not a natural purchase.'); + const beforeBattle = structuredClone(game); + const choice = viewGame(game, game.active!).battleChoices.find(c => c.territory === 'arrakeen'); assert.ok(choice); + const battleStep: WorldPhaseBattleStep = { actor: choice.chooser, action: { type: 'chooseBattle', territory: 'arrakeen', + target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } }; + act(battleStep); + reach(g => !!g.battle && harassHomeworldClean(g) && !g.battle.preparation && g.battle.preLeader?.closed !== false); + const living = (actor: string) => harassHomeworldPlayer(game, actor).leaders.filter(l => !l.dead && !l.usedAt) + .sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id)); + const leader = living(owner).at(-1)!, opponentLeader = living(opponent)[0]; + const support = advanced ? options.support ?? 3 : 0; + const sources = { 'homeworld:atreides': { normal: 3, elite: 0 } }; + const plans: WorldPhaseBattleFixture['plans'] = [ + { actor: owner, action: { type: 'battlePlan', dial: 3, support, leader: leader.id, + weapon: REINFORCEMENTS_CARD, defense: HARASS_WITHDRAW_CARD, reinforcementsSources: sources } }, + { actor: opponent, action: { type: 'battlePlan', dial: 0, support: 0, leader: opponentLeader.id } }, + ]; + const army = combatArmy(game, owner, game.battle!.territory); + assert.deepEqual(army, { normal: 14, elite: 0 }); + const committed = advanced ? 6 - support : 3; + const returns = { [location]: { normal: army.normal - committed, elite: 0 } }; + assertHarassHomeworldCustody(game); + return { initial, afterSetup, beforeFirstMentat, firstMentatStep, afterFirstMentat, shipments, + beforeBattle, battleStep, game, owner, opponent, observer, location, plans, sources, returns, actions, staging }; +} + +/** Exposes exactly the original source-key controls for a genuine own-World + * battle: ten normal Kaitain reserves move to Salusa; Guild pays to invade + * Kaitain. The three fee counters may come from either real normal pool. */ +export function createWorldPhaseOwnBattleFixture(options: { initial?: Game; tech?: boolean } = {}): ReinforcementsHomeworldFixture { + const initial = options.initial ?? createIndependentCardModulesFixture({ advanced: true, homeworlds: true, + tech: options.tech ?? true, strongholds: true, factions: ['emperor', 'guild', 'atreides'] }).initial; + const fixture = createReinforcementsHomeworldFixture({ advanced: true, initial, role: 'native', outcome: 'win', + nativeTransfer: 10, sources: { 'homeworld:emperor:salusa': { normal: 3, elite: 0 } }, ownerDial: 2, ownerSupport: 2 }); + takeHarassCard(fixture.game, fixture.owner, HARASS_WITHDRAW_CARD); + fixture.staging.push('Conserve original Harass & Withdraw from undealt stock into Emperor’s hand solely to exercise the native own-World ban; never seal it in the legal plan.'); + assertHarassHomeworldCustody(fixture.game); + return fixture; +} + +/** Seven original Emperor visitors invade Junction (3N4E), then two normal + * reserves move Kaitain → Salusa. R pays 1N Kaitain + 2N Salusa before H returns + * 2N to Kaitain and 3E to Salusa. Both modules start original and unused. */ +export function createWorldPhaseVisitorBattleFixture(options: { initial?: Game } = {}): ReinforcementsHomeworldFixture { + const initial = options.initial ?? createIndependentCardModulesFixture({ advanced: true, homeworlds: true, + tech: true, strongholds: true, factions: ['emperor', 'guild', 'atreides'] }).initial; + const fixture = createReinforcementsHomeworldFixture({ advanced: true, initial, role: 'visitor', outcome: 'win', + slot: 'weapon', nativeTransfer: 2, ownerDial: 2, ownerSupport: 1, + sources: { 'homeworld:emperor': { normal: 1, elite: 0 }, 'homeworld:emperor:salusa': { normal: 2, elite: 0 } } }); + takeHarassCard(fixture.game, fixture.owner, HARASS_WITHDRAW_CARD); + fixture.planActions[0].action.defense = HARASS_WITHDRAW_CARD; + fixture.staging.push('Conserve original Harass & Withdraw from undealt stock into Emperor’s other physical utility slot; exact post-fee returns remain a human typed allocation.'); + assertHarassHomeworldCustody(fixture.game); + return fixture; +} + +export function revealWorldPhaseBattle(fixture: WorldPhaseBattleFixture, + plans: readonly WorldPhaseBattleStep[] = fixture.plans): Game { + let game = structuredClone(fixture.game); + for (const plan of plans) { + game = actHarassHomeworld(game, plan); + game = advanceWorldPhaseBattle(game, g => harassHomeworldClean(g) && + !g.battle?.preparation && g.battle?.preLeader?.closed !== false); + } + assert.ok(game.battle?.revealed); return game; +} + +/** No decision is cleared or fabricated. Callers can stop before an actual + * Caladan/Tech choice using advanceWorldPhaseBattle and consume its event. */ +export function finishWorldPhaseBattle(state: Game, fixture: WorldPhaseBattleFixture, + actions?: WorldPhaseBattleStep[]): Game { + let game = state; + for (let n = 0; n < 240; n++) { + if (!game.battle && !game.homeworldBattleLoss && harassHomeworldClean(game)) return game; + let step: WorldPhaseBattleStep | null; + if (!game.response && !game.phaseOpening && game.decision?.kind === 'harassWithdraw') { + step = { actor: game.decision.player, action: { type: 'decision', event: game.decision.event, returns: fixture.returns } }; + } else step = nextHarassHomeworldStep(game); + assert.ok(step, 'Seal both original plans before finishing their physical aftermath'); + game = actHarassHomeworld(game, step, actions); + } + throw Error('Original World utility battle did not finish'); +} diff --git a/tests/fixture-world-phase-recruits.ts b/tests/fixture-world-phase-recruits.ts new file mode 100644 index 00000000..077d6a9b --- /dev/null +++ b/tests/fixture-world-phase-recruits.ts @@ -0,0 +1,155 @@ +import assert from 'node:assert/strict'; +import { + applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game, type GameView, +} from '../game/engine'; +import { createTechTokens, ownedTech } from '../game/tech-tokens'; +import { createStrongholdCards } from '../game/stronghold-cards'; +import { RECRUITS_CARD_ID } from '../game/recruits'; +import { + assertRecruitsHomeworldInventory, createRecruitsHomeworldFixture, + recruitsHomeworldClean, recruitsHomeworldPlayer, type RecruitsHomeworldFixture, type RecruitsHomeworldStep, +} from './fixture-recruits-homeworld'; +import { nextHarassHomeworldStep } from './fixture-harass-homeworld'; + +export type WorldPhaseRecruitsOptions = { + advanced?: boolean; + strongholds?: boolean; + seatIds?: readonly [string, string, string, string]; +}; + +export type WorldPhaseRecruitsFixture = { + initial: Game; afterSetup: Game; game: Game; + afterPaidReturn: Game; beforePlay: Game; afterPlay: Game; beforeFremen: Game; + afterTypedReturn: Game; afterReturns: Game; beforePhaseEnd: Game; afterPhaseEnd: Game; + phaseEndStep: RecruitsHomeworldStep; + actors: { emperor: string; fremen: string; atreides: string; guild: string; owner: string }; + actions: Record<'paidReturn' | 'readyAtreides' | 'play' | 'reopenedReturn' | + 'typedReturn' | 'followupReturn', Action>; + ordinary: { fremen: GameView['revival']; atreides: GameView['revival'] }; + expected: { + paidReturnPrice: number; reopenedReturnPrice: number; fremenReturnPrice: number; + income: number; wallets: Record; + doubled: { fremenFree: number; atreidesFree: number; limit: number }; + afterThreshold: { freeRemaining: number; forcesRemaining: number; homeworldBonus: number }; + }; + nativeSteps: RecruitsHomeworldStep[]; steps: RecruitsHomeworldStep[]; staging: string[]; +}; + +export const worldPhaseRecruitsPlayer = recruitsHomeworldPlayer; +export function worldPhaseRecruitsNativePool(game: Game, actor: string) { + const world = viewGame(game, actor).homeworlds?.worlds?.find(w => w.native === actor && !w.secondary); + assert.ok(world, 'The original native World must retain its real custody'); + return world.forces[actor]; +} + +/** Original E/F/A/G setup, paid typed shipments and two actual battles are + * supplied by the canonical World fixture. Only an undealt lobby is composed + * here: no live phase, force, wallet, owner or revival receipt is retrofitted. + * Its existing initializer scopes its own entropy; the first subsequent draw + * is the original first-Storm shuffle of the two unassigned Tech Tokens. + * All later entropy (including Advanced's native Storm deck) delegates intact. */ +export function createWorldPhaseRecruitsFixture(options: WorldPhaseRecruitsOptions = {}): WorldPhaseRecruitsFixture { + const advanced = options.advanced ?? false; + const strongholds = options.strongholds ?? false; + assert.ok(!strongholds || advanced, 'Original Stronghold Cards require Advanced'); + const ids = options.seatIds ?? ['emperor', 'fremen', 'atreides', 'guild']; + const [emperor, fremen, atreides, guild] = ids; + let lobby = createGame('WORLDPHASERECRUITS', newPlayer(emperor, 'Emperor', 'emperor'), advanced); + for (const [id, faction] of [[fremen, 'fremen'], [atreides, 'atreides'], [guild, 'guild']] as const) + joinGame(lobby, newPlayer(id, faction, faction)); + lobby = applyAction(lobby, emperor, { type: 'homeworlds', enabled: true }); + lobby.techTokens = createTechTokens(); + if (strongholds) lobby.strongholdCards = createStrongholdCards(); + const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); + // Binding loses Crypto's generic guarantee that it returns the same view. + const originalRandom = crypto.getRandomValues.bind(crypto) as (array: T) => T; + let firstStormDraw = true; + crypto.getRandomValues = (array: T): T => { + if (!firstStormDraw) return originalRandom(array); + assert.ok(array); + firstStormDraw = false; + new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255); + return array; + }; + let source: RecruitsHomeworldFixture; + try { source = createRecruitsHomeworldFixture({ advanced, initial: lobby, seatIds: ids }); } + finally { + if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); + else Reflect.deleteProperty(crypto, 'getRandomValues'); + } + assert.equal(firstStormDraw, false, 'The original first Storm must assign unused Tech Tokens'); + let game = structuredClone(source.game); + const steps: RecruitsHomeworldStep[] = []; + const act = (actor: string, action: Action) => { + steps.push({ actor, action: structuredClone(action) }); + game = applyAction(game, actor, action); + }; + const settle = () => { + for (let count = 0; count < 100; count++) { + if (recruitsHomeworldClean(game)) return; + const next = nextHarassHomeworldStep(game); assert.ok(next); + act(next.actor, next.action); + } + throw Error('Original World revival decisions did not settle'); + }; + const actions = { + paidReturn: { type: 'revive', amount: 3, elite: 0 }, + readyAtreides: { type: 'ready' }, + play: { type: 'card', card: RECRUITS_CARD_ID }, + reopenedReturn: { type: 'revive', amount: 2, elite: 0 }, + typedReturn: { type: 'revive', amount: 1, elite: 1 }, + followupReturn: { type: 'revive', amount: 5, elite: 0 }, + } satisfies Record; + const owner = game.techTokens?.axlotl.owner; assert.ok(owner); + const income = ownedTech(game.techTokens, owner).length; + assert.ok(income > 0); + const ordinary = { + fremen: structuredClone(viewGame(game, fremen).revival), + atreides: structuredClone(viewGame(game, atreides).revival), + }; + const wallets = Object.fromEntries(game.players.map(p => [p.id, p.spice])); + act(atreides, actions.paidReturn); settle(); + const afterPaidReturn = structuredClone(game); + act(atreides, actions.readyAtreides); + const beforePlay = structuredClone(game); + act(emperor, actions.play); + const afterPlay = structuredClone(game); + act(atreides, actions.reopenedReturn); settle(); + const beforeFremen = structuredClone(game); + act(fremen, actions.typedReturn); settle(); + const afterTypedReturn = structuredClone(game); + act(fremen, actions.followupReturn); settle(); + const afterReturns = structuredClone(game); + let beforePhaseEnd: Game | undefined; + let phaseEndStep: RecruitsHomeworldStep | undefined; + for (let count = 0; count < 100 && game.phase === 4; count++) { + const next = nextHarassHomeworldStep(game); assert.ok(next); + const before = structuredClone(game); + act(next.actor, next.action); + if (game.phase !== 4) { beforePhaseEnd = before; phaseEndStep = structuredClone(next); } + } + assert.ok(beforePhaseEnd && phaseEndStep, 'The original last ready must settle Revival'); + assert.equal(game.phase, 5); + const afterPhaseEnd = structuredClone(game); + for (const boundary of [source.afterSetup, source.game, afterPaidReturn, beforePlay, afterPlay, + beforeFremen, afterTypedReturn, afterReturns, beforePhaseEnd, afterPhaseEnd]) + assertRecruitsHomeworldInventory(boundary); + return { + initial: source.initial, afterSetup: source.afterSetup, game: source.game, + afterPaidReturn, beforePlay, afterPlay, beforeFremen, afterTypedReturn, afterReturns, + beforePhaseEnd, phaseEndStep, afterPhaseEnd, + actors: { emperor, fremen, atreides, guild, owner }, actions, ordinary, + expected: { + paidReturnPrice: 2, reopenedReturnPrice: 0, fremenReturnPrice: 0, + income, wallets, + doubled: { fremenFree: 7, atreidesFree: 4, limit: 7 }, + afterThreshold: { freeRemaining: 5, forcesRemaining: 5, homeworldBonus: 0 }, + }, + nativeSteps: source.nativeSteps, steps, + staging: [...source.staging, + 'Select unused original Tech Tokens and optional Advanced Strongholds in the undealt lobby, before readiness and original setup.', + 'Fix only the original first-Storm unused-token shuffle; restore native later Storm entropy. Custody and held-token count are read, never assigned.', + 'Atreides pays for three original native returns before Recruits; its recorded paid counter remains paid after reopening. Fremen then returns one typed Fedaykin and five normal counters on separate current-rate requests.', + ], + }; +} diff --git a/tests/world-phase-battle.test.ts b/tests/world-phase-battle.test.ts new file mode 100644 index 00000000..a923b298 --- /dev/null +++ b/tests/world-phase-battle.test.ts @@ -0,0 +1,190 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, viewGame, type Game } from '../game/engine'; +import { combatArmy } from '../game/combat-location'; +import { REINFORCEMENTS_CARD } from '../game/reinforcements'; +import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw'; +import { harassHomeworldPool as pool, finishHarassHomeworldBattle } from './fixture-harass-homeworld'; +import { + finishReinforcementsHomeworldBattle, revealReinforcementsHomeworldBattle, +} from './fixture-reinforcements-homeworld'; +import { + actWorldPhaseBattle, advanceWorldPhaseBattle, assertWorldPhaseBattleInventory, + createWorldPhaseBattleFixture, createWorldPhaseOwnBattleFixture, createWorldPhaseVisitorBattleFixture, + finishWorldPhaseBattle, revealWorldPhaseBattle, worldPhaseBattleClean, + worldPhaseBattlePlayer as player, type WorldPhaseBattleFixture, +} from './fixture-world-phase-battle'; + +function assertCompletedUtilityBattle(game: Game, f: WorldPhaseBattleFixture): void { + assert.equal(game.turn, 2); + assert.equal(game.phase, 6, 'A second paid native conflict prevents Collection from hiding battle economics'); + assert.equal(game.battle, null); + assert.ok(worldPhaseBattleClean(game)); + assert.equal(game.lastBattleContext!.winner, f.owner); + assert.equal(game.lastBattleContext!.result, 'normal'); + for (const id of [REINFORCEMENTS_CARD, HARASS_WITHDRAW_CARD]) { + assert.equal(game.discard.filter(card => card.id === id).length, 1); + assert.ok(!player(game, f.owner).hand.some(card => card.id === id)); + } + assert.deepEqual(combatArmy(game, f.observer, 'homeworld:guild'), { normal: 1, elite: 0 }, + 'The original paid Emperor visitor remains outside Player.forces and native reserve totals'); + assert.equal(player(game, f.observer).reserves, 19); + assert.deepEqual(pool(game, f.opponent, 'homeworld:guild'), { normal: 11, elite: 0 }); + assertWorldPhaseBattleInventory(game); +} + +for (const support of [0, 1, 3]) { + void test(`earned Arrakeen + original Tech: support ${support} pays only actual support, then exact native R/H custody`, () => { + const f = createWorldPhaseBattleFixture({ support }); + assert.equal(f.beforeFirstMentat.strongholdCards!.owners.arrakeen, null); + assert.equal(player(f.beforeFirstMentat, f.owner).forces[f.location], 10); + assert.equal(f.afterFirstMentat.strongholdCards!.owners.arrakeen, f.owner); + assert.equal(f.afterFirstMentat.strongholdCards!.claimedTurn, 1); + + const ownShipment = f.shipments.find(s => s.step.actor === f.owner)!; + const attack = f.shipments.find(s => s.step.actor === f.opponent)!; + assert.equal(player(ownShipment.before, f.owner).spice - player(ownShipment.after, f.owner).spice, 4, + 'Four original Caladan counters pay their native four-spice invoice'); + assert.equal(player(attack.before, f.opponent).spice - player(attack.after, f.opponent).spice, 2, + 'Four original Guild invaders pay their real native half-price invoice'); + assert.deepEqual(pool(f.game, f.owner, 'homeworld:atreides'), { normal: 6, elite: 0 }); + assert.deepEqual(combatArmy(f.game, f.owner, f.game.battle!.territory), { normal: 14, elite: 0 }); + assert.equal(f.game.techTokens!.heighliners.owner, f.opponent); + assert.equal(f.game.techTokens!.heighliners.spice, 0); + + const revealed = revealWorldPhaseBattle(f); + assert.equal(player(revealed, f.owner).reserves, 6, 'Sealing and reveal do not prematurely spend counters'); + const done = finishWorldPhaseBattle(revealed, f); + const committed = 6 - support; + const returned = 14 - committed; + assert.equal(player(done, f.owner).spice, player(f.game, f.owner).spice - Math.max(0, support - 2), + 'Arrakeen covers at most two actually selected support; it never mints personal spice'); + assert.equal(player(done, f.owner).tanks, 3 + committed, + 'Three off-World native fee counters plus only the physical committed Arrakeen complement die'); + assert.equal(player(done, f.owner).battleLosses, 3 + committed); + assert.equal(player(done, f.owner).reserves, 3 + returned, + 'Original Caladan6 pays R3 before all undialed board counters return to that actual native pool'); + assert.deepEqual(pool(done, f.owner, 'homeworld:atreides'), { normal: 3 + returned, elite: 0 }); + assert.deepEqual(combatArmy(done, f.owner, f.game.battle!.territory), { normal: 0, elite: 0 }); + assert.equal(player(done, f.opponent).tanks, 4); + assert.equal(player(done, f.opponent).spice, player(f.game, f.opponent).spice, + 'Winning does not retroactively redirect the former owner’s Heighliners income'); + assert.equal(done.techTokens!.heighliners.owner, f.owner, 'Original winner Tech transfer follows the actual Guild defeat'); + assert.equal(done.techTokens!.heighliners.spice, 0); + assert.equal(viewGame(done, f.owner).homeworlds?.worlds?.find(w => w.native === f.owner)?.side, 'high'); + assertCompletedUtilityBattle(done, f); + }); +} + +void test('Advanced earned Strongholds alone: utility fee cannot turn six native reserves into extra Arrakeen dial', () => { + const f = createWorldPhaseBattleFixture({ tech: false, strongholds: true }); + const tooMany = structuredClone(f.plans[0].action); + tooMany.dial = 8.5; tooMany.support = 2; + assert.throws(() => applyAction(f.game, f.owner, tooMany), + 'Fifteen physical board commitments cannot borrow any of the six native reserves, even with R'); + const wrongSource = structuredClone(f.plans[0].action); + wrongSource.reinforcementsSources = { 'homeworld:guild': { normal: 3, elite: 0 } }; + assert.throws(() => applyAction(f.game, f.owner, wrongSource), 'Only original native reserves can pay this fee'); + const done = finishWorldPhaseBattle(revealWorldPhaseBattle(f), f); + assert.equal(player(done, f.owner).spice, player(f.game, f.owner).spice - 1); + assert.equal(player(done, f.opponent).spice, player(f.game, f.opponent).spice, + 'No post-battle income may be fabricated when the Tech module is absent'); + assert.equal(player(done, f.owner).tanks, 6); + assert.deepEqual(pool(done, f.owner, 'homeworld:atreides'), { normal: 14, elite: 0 }); + assertCompletedUtilityBattle(done, f); +}); + +void test('Basic Worlds + original Tech: native paid attack and R/H retain Basic forces and no support charge', () => { + const f = createWorldPhaseBattleFixture({ advanced: false, tech: true, strongholds: false }); + const tooMany = structuredClone(f.plans[0].action); tooMany.dial = 15; + assert.throws(() => applyAction(f.game, f.owner, tooMany), 'The native fee is never an additional Basic board army'); + const done = finishWorldPhaseBattle(revealWorldPhaseBattle(f), f); + assert.equal(player(done, f.owner).spice, player(f.game, f.owner).spice, 'Basic pays no force support'); + assert.equal(player(done, f.owner).tanks, 6); + assert.deepEqual(pool(done, f.owner, 'homeworld:atreides'), { normal: 14, elite: 0 }); + assert.equal(viewGame(done, f.owner).strongholdCards, null); + assert.equal(done.techTokens!.heighliners.owner, f.owner); + assertCompletedUtilityBattle(done, f); +}); + +void test('native current Caladan face: a surviving original army consumes the actual reinforcement decision', () => { + const f = createWorldPhaseBattleFixture(); + const plans = structuredClone(f.plans); + plans[0].action.weapon = null; plans[0].action.defense = null; + delete plans[0].action.reinforcementsSources; + let game = revealWorldPhaseBattle(f, plans); + game = advanceWorldPhaseBattle(game, g => !g.response && !g.phaseOpening && g.decision?.kind === 'caladanReinforcement'); + assert.equal(game.decision!.player, f.owner); + assert.equal(player(game, f.owner).reserves, 6); + assert.equal(player(game, f.owner).tanks, 3); + assert.equal(player(game, f.owner).forces[f.location], 11); + const beforeSpice = player(game, f.owner).spice; + const offer = viewGame(game, f.owner).caladanReinforcement!; + const destination = offer.destinations.find(d => d.id === f.location && !d.blocked); assert.ok(destination); + game = actWorldPhaseBattle(game, { actor: f.owner, + action: { type: 'decision', event: offer.event, destination: destination.id } }); + game = advanceWorldPhaseBattle(game, g => !g.battle && worldPhaseBattleClean(g)); + assert.equal(player(game, f.owner).reserves, 5, 'Exactly one real Caladan normal counter leaves its native pool'); + assert.equal(player(game, f.owner).forces[f.location], 12); + assert.equal(player(game, f.owner).tanks, 3); + assert.equal(player(game, f.owner).spice, beforeSpice, 'The native reinforcement is not a second paid shipment'); + assert.equal(viewGame(game, f.owner).homeworlds?.worlds?.find(w => w.native === f.owner)?.side, 'low', + 'The actual native6→5 transfer immediately crosses the printed Caladan threshold'); + assert.equal(game.lastBattleContext!.caladanReinforcement!.completed, true); + assert.equal(game.techTokens!.heighliners.owner, f.owner); + assertWorldPhaseBattleInventory(game); +}); + +void test('own-World R: local fee counters are disjoint from dial, off-World normals pay physically, and H remains forbidden', () => { + const f = createWorldPhaseOwnBattleFixture(); + assert.deepEqual(pool(f.game, f.owner, 'homeworld:emperor'), { normal: 5, elite: 0 }); + assert.deepEqual(pool(f.game, f.owner, 'homeworld:emperor:salusa'), { normal: 10, elite: 5 }); + assert.deepEqual(pool(f.game, f.opponent, f.location), { normal: 5, elite: 0 }, 'The original paid Guild invaders retain separate visitor custody'); + const local = structuredClone(f.planActions[0].action); + local.dial = 3; local.support = 3; + local.reinforcementsSources = { 'homeworld:emperor': { normal: 3, elite: 0 } }; + assert.throws(() => applyAction(f.game, f.owner, local), 'Kaitain5 cannot both pay its own three and dial three'); + const elite = structuredClone(f.planActions[0].action); + elite.reinforcementsSources = { 'homeworld:emperor:salusa': { normal: 0, elite: 3 } }; + assert.throws(() => applyAction(f.game, f.owner, elite), 'Existing native normals must pay before any real Sardaukar'); + const forbidden = structuredClone(f.planActions[0].action); forbidden.weapon = HARASS_WITHDRAW_CARD; + assert.throws(() => applyAction(f.game, f.owner, forbidden), 'An original H card in hand never permits own-World withdrawal'); + f.planActions[0].action.dial = 3; f.planActions[0].action.support = 3; + const done = finishReinforcementsHomeworldBattle(revealReinforcementsHomeworldBattle(f)); + assert.equal(done.lastBattleContext!.winner, f.owner); + assert.deepEqual(pool(done, f.owner, 'homeworld:emperor'), { normal: 2, elite: 0 }); + assert.deepEqual(pool(done, f.owner, 'homeworld:emperor:salusa'), { normal: 7, elite: 5 }, + 'Exactly three original Salusa normals pay R; none of the five original Sardaukar substitutes'); + assert.equal(player(done, f.owner).reserves, 14); + assert.equal(player(done, f.owner).tanks, 6); + assert.equal(player(done, f.owner).elites!.tanks, 0); + assert.equal(player(done, f.opponent).tanks, 5); + assert.deepEqual(pool(done, f.opponent, f.location), { normal: 0, elite: 0 }); + assert.equal(done.discard.filter(c => c.id === REINFORCEMENTS_CARD).length, 1); + assert.ok(player(done, f.owner).hand.some(c => c.id === HARASS_WITHDRAW_CARD), 'Rejected H was never used or discarded'); + assert.equal(viewGame(done, f.owner).homeworlds?.worlds?.find(w => w.id === f.location)?.side, 'low'); + assertWorldPhaseBattleInventory(done); +}); + +void test('foreign World R+H: original split normal fee precedes exact normal/elite deposits, never tanks or visitor reserves', () => { + const f = createWorldPhaseVisitorBattleFixture(); + assert.deepEqual(pool(f.game, f.owner, 'homeworld:emperor'), { normal: 10, elite: 0 }); + assert.deepEqual(pool(f.game, f.owner, 'homeworld:emperor:salusa'), { normal: 2, elite: 1 }); + assert.deepEqual(pool(f.game, f.owner, f.location), { normal: 3, elite: 4 }); + const done = finishHarassHomeworldBattle(revealReinforcementsHomeworldBattle(f), + { returns: { [f.location]: { normal: 2, elite: 3 } } }); + assert.equal(done.lastBattleContext!.winner, f.owner); + assert.deepEqual(pool(done, f.owner, 'homeworld:emperor'), { normal: 11, elite: 0 }, 'Kaitain10 − fee1 + return2'); + assert.deepEqual(pool(done, f.owner, 'homeworld:emperor:salusa'), { normal: 0, elite: 4 }, 'Salusa2N1E − fee2N + return3E'); + assert.deepEqual(pool(done, f.owner, f.location), { normal: 0, elite: 0 }); + assert.equal(player(done, f.owner).reserves, 15); + assert.equal(player(done, f.owner).elites!.reserves, 4); + assert.equal(player(done, f.owner).tanks, 5, 'The fee3N and committed 1N1E die; the undialed 2N3E do not'); + assert.equal(player(done, f.owner).elites!.tanks, 1); + assert.equal(player(done, f.opponent).reserves, 0); + assert.equal(player(done, f.opponent).tanks, 15, 'Only the genuine native Junction army dies, not its original five board counters'); + assert.equal(player(done, f.opponent).forces['tueks_sietch:5'], 5); + for (const id of [REINFORCEMENTS_CARD, HARASS_WITHDRAW_CARD]) assert.equal(done.discard.filter(c => c.id === id).length, 1); + assert.equal(done.battle, null); assert.ok(worldPhaseBattleClean(done)); + assertWorldPhaseBattleInventory(done); +}); diff --git a/tests/world-phase-recruits.test.ts b/tests/world-phase-recruits.test.ts new file mode 100644 index 00000000..45950b36 --- /dev/null +++ b/tests/world-phase-recruits.test.ts @@ -0,0 +1,124 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { ownedTech } from '../game/tech-tokens'; +import { RECRUITS_CARD_ID } from '../game/recruits'; +import { assertRecruitsHomeworldInventory } from './fixture-recruits-homeworld'; +import { + createWorldPhaseRecruitsFixture, worldPhaseRecruitsNativePool as pool, + worldPhaseRecruitsPlayer as player, +} from './fixture-world-phase-recruits'; + +function rejectsUnchanged(game: Game, actor: string, action: Action): void { + const before = JSON.stringify(game); + assert.throws(() => applyAction(game, actor, action)); + assert.equal(JSON.stringify(game), before, 'Rejected spending/returns must preserve the actual programme'); +} + +const cases = [ + { name: 'Basic World + Tech', advanced: false, strongholds: false }, + { name: 'Advanced World + Tech', advanced: true, strongholds: false }, + { name: 'Advanced World + Tech + earned Strongholds', advanced: true, strongholds: true }, +]; + +for (const options of cases) { + void test(`${options.name}: original casualties, useful doubled typed returns and one delayed Axlotl payment`, () => { + const f = createWorldPhaseRecruitsFixture(options); + const { emperor, fremen, atreides, guild, owner } = f.actors; + const start = f.game; + assert.equal(player(start, fremen).reserves, 2); + assert.equal(player(start, fremen).tanks, 18); + assert.equal(player(start, fremen).elites!.tanks, 3); + assert.equal(player(start, atreides).tanks, 10); + assert.equal(start.techTokens!.production.owner, emperor, 'The real Fremen loss transfers its original token'); + assert.equal(ownedTech(start.techTokens, owner).length, 2); + assert.equal(start.techTokens!.axlotl.spice, 0); + + assert.deepEqual({ free: f.ordinary.fremen.freeRemaining, limit: f.ordinary.fremen.limit, + bonus: f.ordinary.fremen.homeworldBonus }, { free: 4, limit: 4, bonus: 1 }); + assert.deepEqual({ free: f.ordinary.atreides.freeRemaining, limit: f.ordinary.atreides.limit }, + { free: 2, limit: 3 }); + const paid = player(f.afterPaidReturn, atreides); + assert.deepEqual({ revived: paid.revived, free: paid.freeForcesRevived, reserves: paid.reserves, + tanks: paid.tanks, spice: paid.spice }, + { revived: 3, free: 2, reserves: 13, tanks: 7, spice: player(start, atreides).spice - 2 }); + assert.ok(f.beforePlay.ready.includes(atreides)); + assert.equal(viewGame(f.afterPlay, fremen).revival.freeRemaining, 7); + assert.equal(viewGame(f.afterPlay, fremen).revival.limit, 7); + assert.equal(viewGame(f.afterPlay, atreides).revival.freeRemaining, 2, + 'Doubled free allowance subtracts the recorded two free returns, not all three returns'); + assert.equal(viewGame(f.afterPlay, atreides).revival.forcesRemaining, 4); + const reopened = player(f.beforeFremen, atreides); + assert.deepEqual({ revived: reopened.revived, free: reopened.freeForcesRevived, + reserves: reopened.reserves, tanks: reopened.tanks, spice: reopened.spice }, + { revived: 5, free: 4, reserves: 15, tanks: 5, spice: paid.spice }); + assert.deepEqual(pool(f.beforeFremen, atreides), { normal: 15, elite: 0 }); + + assert.deepEqual(pool(f.afterTypedReturn, fremen), { normal: 2, elite: 1 }); + const current = viewGame(f.afterTypedReturn, fremen).revival; + assert.deepEqual({ freeRemaining: current.freeRemaining, forcesRemaining: current.forcesRemaining, + homeworldBonus: current.homeworldBonus }, { freeRemaining: 5, forcesRemaining: 5, homeworldBonus: 0 }); + assert.equal(current.eliteRemaining, options.advanced ? 0 : 2); + rejectsUnchanged(f.afterTypedReturn, fremen, { type: 'revive', amount: 6, elite: 0 }); + if (options.advanced) + rejectsUnchanged(f.afterTypedReturn, fremen, { type: 'revive', amount: 1, elite: 1 }); + const returned = player(f.afterReturns, fremen); + assert.deepEqual({ revived: returned.revived, free: returned.freeForcesRevived, + reserves: returned.reserves, tanks: returned.tanks, spice: returned.spice }, + { revived: 6, free: 6, reserves: 8, tanks: 12, spice: player(start, fremen).spice }); + assert.equal(returned.elites!.reserves, 1); assert.equal(returned.elites!.tanks, 2); + assert.deepEqual(pool(f.afterReturns, fremen), { normal: 7, elite: 1 }); + assert.equal(viewGame(f.afterReturns, fremen).revival.forcesRemaining, 0); + assert.equal(f.afterReturns.discard.filter(card => card.id === RECRUITS_CARD_ID).length, 1); + + for (const boundary of [f.afterPaidReturn, f.afterPlay, f.beforeFremen, + f.afterTypedReturn, f.afterReturns, f.beforePhaseEnd]) { + assert.equal(boundary.techTokens!.axlotl.spice, 2); + assert.equal(boundary.techTokens!.axlotl.triggeredTurn, 2); + assert.equal(player(boundary, owner).spice, player(start, owner).spice, + 'Accrued Axlotl spice is not a spendable wallet credit'); + } + const available = player(f.afterReturns, owner).spice; + const bribes = viewGame(f.afterReturns, owner).bribeOptions; + assert.equal(bribes.available, available); + assert.equal(bribes.blocked, null, 'The spending rejection must be a wallet boundary, not a timing gate'); + assert.equal(bribes.targets.find(target => target.id === fremen)?.maximum, available); + rejectsUnchanged(f.afterReturns, owner, + { type: 'bribe', target: fremen, amount: available + f.expected.income }); + const settled = applyAction(f.beforePhaseEnd, f.phaseEndStep.actor, f.phaseEndStep.action); + assert.equal(settled.phase, 5); + assert.equal(player(settled, owner).spice, available + 2); + assert.equal(settled.techTokens!.axlotl.spice, 0); + assert.equal(settled.techTokens!.axlotl.triggeredTurn, 2); + assert.equal(player(settled, atreides).spice, paid.spice, + 'Recruits and settlement never refund the earlier paid return'); + assert.equal(player(settled, atreides).revived, 5); + assert.equal(player(settled, atreides).freeForcesRevived, 4); + assert.equal(player(settled, fremen).revived, 6); + assert.equal(player(settled, fremen).freeForcesRevived, 6); + + if (options.strongholds) { + assert.equal(start.strongholdCards!.claimedTurn, 1); + assert.equal(start.strongholdCards!.owners.sietch_tabr, emperor); + assert.equal(start.strongholdCards!.owners.arrakeen, guild); + assert.equal(start.strongholdCards!.owners.tueks_sietch, guild); + } + for (const boundary of [start, f.afterPaidReturn, f.afterPlay, f.afterTypedReturn, + f.afterReturns, settled]) assertRecruitsHomeworldInventory(boundary); + }); +} + +void test('A bulk native low-population request locks its quoted seven, unlike the six separate typed returns', () => { + const f = createWorldPhaseRecruitsFixture(); + const { fremen } = f.actors; + const bulk = applyAction(f.beforeFremen, fremen, { type: 'revive', amount: 7, elite: 0 }); + assert.equal(player(bulk, fremen).revived, 7); + assert.equal(player(bulk, fremen).freeForcesRevived, 7); + assert.equal(player(bulk, fremen).spice, player(f.beforeFremen, fremen).spice); + assert.equal(player(bulk, fremen).tanks, 11); + assert.equal(player(bulk, fremen).elites!.tanks, 3); + assert.deepEqual(pool(bulk, fremen), { normal: 9, elite: 0 }); + assert.equal(bulk.techTokens!.axlotl.spice, f.expected.income, + 'Another eligible bulk return cannot accrue a second payment in the same original phase'); + assertRecruitsHomeworldInventory(bulk); +}); diff --git a/tools/start-prototype.ts b/tools/start-prototype.ts index 89f4df07..ccce3de9 100644 --- a/tools/start-prototype.ts +++ b/tools/start-prototype.ts @@ -36,7 +36,7 @@ async function main() { '\nPrinted native Homeworld capture/Auditor/force-income Karama cancellation costs also offer their card. Accepted combat, paid support, leader death and original casualty/cleanup remain committed; the canceled reward never samples, transfers, inspects, refunds or credits. Earlier allowed income stays intact before a separately canceled audit.', '\n--profile homeworld-occupation --leader-skills composes original all14 Leader Skills with the classic2–6-seat occupied Homeworld income preview. Native setup, reserve custody, occupation history and owner allocation remain original. Bureaucrat diverts two from gross before the occupied percentage split while preserving the full original payer shares. Nexus Cards, expansions, Tech, Strongholds, Discovery, other overlays and conversion of played games remain excluded.', '\n--profile guild-betrayal --leader-skills composes the bounded classic native-Guild Nexus preview with original all14 skills. Original payment/physical shipment and once-per-phase Bureaucrat receipt remain; ordinary Skills use their shared quotes. Homeworlds, expansions, Tech, Strongholds, Discovery, Semuta, other overlays and conversion of played games remain excluded.', - '\n--profile ecaz-treachery --homeworlds selects independent36-card classic2–6 Basic/Advanced Homeworld setup. Recruits doubles current native rates within seven total, provisionally rechecking separate-request thresholds/prior free use with native typed deposits/caps. Harass returns exact undialed counters through native reserves, never on an own World. Reinforcements seals exact normal-first native sources when ambiguous, pays before Harass and excludes an on-World fee from physical dial/casualties while preserving original face/support. Named-card Truthtrance and source-aware legal completion use canonical cards/locations. These are bounded provisional compositions, not full variant certification. No expansions, other overlays, public start or played-save conversion.', + '\n--profile ecaz-treachery --homeworlds selects independent36-card classic2–6 Basic/Advanced Homeworld setup, retaining original unused Tech3+ and/or Advanced Strongholds2+; both needs Advanced3+. Native custody, first-Storm Tech and earned end-Mentat cards remain. Recruits doubles current native rates within seven total, provisionally rechecking separate-request thresholds/prior free use and original Axlotl timing. Harass returns exact native counters, never on an own World. Reinforcements seals exact normal-first sources when ambiguous, pays before Harass and excludes an on-World fee from physical dial/casualties while preserving original face/support. Earned bank subsidy funds only real support. No expansions, Skills, Nexus, Discovery, other overlays, public start or played-save conversion.', '\nWithout Homeworlds, ecaz-treachery supports clean classic or selected Ix and/or CHOAM native/base Basic/Advanced rosters with original unused Tech3+ and/or Advanced Strongholds2+; both requires Advanced3+. Original selected33/35/47 decks gain the three independent cards; Richese cache stays separate. First-Storm Tech owners/income and actual end-Mentat Stronghold ownership remain original. Recruits keeps original Axlotl triggers; Reinforcements/Harass keep physical costs/returns and earned held-card effects. Original Stone/Mirror allocation/profile guards stay. Nexus, Skills, Discovery, Sandtrout, mixed E3 and unrelated overlays remain outside this entry.', '\nSelected Ix/CHOAM original Stone and physical Mirror now also retain original Tech/Advanced Strongholds, in ordinary47 or independently selected50-card setups with a separate cache. Earned Habbanya decides physical ties; original Stone-before-Tooth remains provisional, printed copy-first and user-selected physical Mirror retention stay. Public winner-changing allocations, mixed E3 phase modules and other overlay/public-start guards remain.', '\nEcaz-occupy (--profile ecaz-occupy) opts a fresh ready2–6 native Ecaz/classic or selected Advanced native-family lobby. Basic uses exact ecaz33 and provisionally follows the main paragraph for odd/even counts: ceil contribution/loss and floor survivors; the conflicting FAQ is recorded, not adjudicated. Advanced retains original selected ecaz/ix/choam decks and native offers/HMS/Auditor/Face Dancers/cache. Explicit --ecaz-treachery independently adds three physical cards in Advanced. Native Richese ordinary, marker-only and mixed pools compose with selected leads under the provisional additive interpretation. Ordinary dial Prescience remains barred for the physical No-Field army; distinct Advanced special Karama uses the explicit complete private-plan interpretation without early token revelation. Other profile/module and public-start gates remain.' +