diff --git a/components/game-table.tsx b/components/game-table.tsx index 2aacd8e3..e6950eb9 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -5396,7 +5396,7 @@ export function GameTable({ {g.homeworlds && g.battle.harassWithdraw && (

- Classic Homeworld timing: actual undialed counters + Homeworld timing: actual undialed counters return to your native reserve Homeworlds, never to the Tanks. In Advanced, Emperor normal forces return to Kaitain and Sardaukar to Salusa; Basic retains diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 9925cdca..3d175ef6 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -1,6 +1,6 @@ # Current development status -Updated 16 October 2026. **The full twelve-faction goal is unfinished. Basic +Updated 17 October 2026. **The full twelve-faction goal is unfinished. Basic is playable and the six classic factions have a user-authorized Advanced preview. Expansion starts and full rules/publication acceptance remain gated.** @@ -29,6 +29,27 @@ optional skill bands, combinations and public expansion starts remain open. Rules implementation continues; comprehensive assurance and administration remain deferred until all rules are implemented. +**Native E1/E2 World independent cards — 17 October.** Clean selected Ix/CHOAM +native/base Basic/Advanced Worlds retain original38/50 setup without phase +modules; classic base36 Tech/Strongholds stays separate. Shared roster checks +and renamed World profile have no alias. No E3, Skills, Nexus, Discovery, +Sandtrout or other preview/public-start/played-save gate opens. +Ten native setups and genuine fee/aftercare, marker-budget and revival programmes +preserve actual stock. Marker3/reserves5 preview/API now agree on rejection +before sealing; H-only remains legal and no early marker reveal is introduced. +Five390px journeys/42 actions complete native H fallback,1N2E fee before7N return, +one actual board-Cyborg exchange/native Face Dance, unlimited11-force revival +and real native Auditor inspection. Other native queues remain original. +Four policies finish28 battle programmes/112 continuations/2230 R-or-H proposals, +24 marker programmes/120 continuations/3050 proposals and40 useful native Revival +programmes/300 actions. Conserved card/identity staging is explicit. Parent +consolidated marker slots and removed fixture/text echoes. Frozen21-file union +passes251/251; types/lint, build and actual prototype help pass. The direct +marker3/reserves5 runtime probe confirms unchanged rejection and legal H-only. +Test cleanup removes transient-window/starting-wallet pins and observes audit +payment before unrelated Collection income. Existing build warnings remain. +Full modes, calibration and deployed proof stay open. + **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 diff --git a/docs/ECAZ_TREACHERY_RULES.md b/docs/ECAZ_TREACHERY_RULES.md index 5fde09e6..97e30540 100644 --- a/docs/ECAZ_TREACHERY_RULES.md +++ b/docs/ECAZ_TREACHERY_RULES.md @@ -222,3 +222,39 @@ 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. +## Native selected-family World composition — 17 October 2026 + +Clean selected Ix and/or CHOAM native/base Basic/Advanced2–6 Worlds now compose +the independent cards before original setup: selected38/50, with Richese's +ten-card cache separate when present. Original HMS/seven Cyborgs, Face Dancers, +Auditor, marker identities and native typed custody remain. These selected-family +Worlds do not add phase modules, Skills, Nexus, Discovery, Sandtrout, E3 or other +previews; classic base36 World Tech/Strongholds remains its separate profile. +Public starts and played-game conversion stay gated. + +One shared selected-family roster rule replaces duplicate checks. The renamed +private World profile retains no classic-name alias. Use original current native +revival rates/bonuses, special-force caps and unlimited Tleilaxu/CHOAM allowance; +Recruits does not reduce an unlimited allowance to seven. Native/foreign World +R fees remain normal-first, source-bearing and disjoint from own-World physical +dials, with original traits/printed face. H remains forbidden on every own World +and returns actual normal/elite counters through native original custody. + +Richese forecasts its own No-Field before R funding, not before physical reveal. +An actual native marker3/reserves5 position displayed a blocked2-reserve owner +preview but accepted the raw first plan after profile admission. The central +native-cost check now rejects the same position unchanged and permits H alone. +Actual reveal later debits ordinary primary-World reserves once; no early +counter move, source/marker/history reset or new saved field is introduced. +Future H returns cannot fund that fee. Existing provisional all-outcome payment, +current-rate request timing and aftercare/retention interpretations are inherited, +not new publisher rulings; other card-specific profile guards stay. + +Bounded proof: frozen related21-file union251/251, types/lint, build and actual +prototype help pass. Five390px journeys/42 actions exercise native typed fees, +board-only Cyborg exchange/Face Dance, marker fallback, unlimited revival and +Auditor inspection. Direct marker3/reserves5 execution rejects unchanged while +H-only seals. Four-policy programmes retain useful conserved-card staging, +not strategy/calibration. Existing build warnings, complete modes and deployed +acceptance remain open; no saved-game/database assurance campaign was run. + diff --git a/docs/HARASS_WITHDRAW_RUNTIME.md b/docs/HARASS_WITHDRAW_RUNTIME.md index bf40d466..81df2952 100644 --- a/docs/HARASS_WITHDRAW_RUNTIME.md +++ b/docs/HARASS_WITHDRAW_RUNTIME.md @@ -145,7 +145,8 @@ 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. 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. +Selected E1/E2 Worlds separately use the [native no-phase-module contract](ECAZ_TREACHERY_RULES.md#native-selected-family-world-composition--17-october-2026); +other rosters/overlays, public starts and save conversion remain closed. Recruits has a separate [native current-rate composition](RECRUITS_RUNTIME.md#classic-homeworld-composition--16-october-2026); Reinforcements now uses an diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 25e2dabe..d36c85d4 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,67 @@ # Dune implementation status +## 17 October 2026 — Selected native E1/E2 World independent cards + +Actual ready Ix/Tleilaxu/CHOAM/Richese World lobbies rejected the classic-only +gate before setup. Admit only clean selected Ix/CHOAM native/base2–6 Basic/ +Advanced, original38/50 plus separate Richese cache when seated, without other +modules. Original HMS/Cyborg7, Face Dancers, Auditor and marker custody remain. +Classic base36 World Tech/Strongholds stays separate; no E3, Skills, Nexus, +Discovery/Sandtrout/other preview, normal start or played-save conversion opens. + +Share one selected-family roster rule; rename the private World profile and +migrate every caller without an alias. Existing native typed sources, own-World +fee/dial disjointness, traits/face, native-only declarations, aftercare, current +rates and unlimited allowances remain. Materialization-before-fee-before-H, +normal-first/all-outcome payment and separate-request rate timing are inherited +provisional policies, not new publisher rulings. + +Parent reproduced a real native World marker3/reserves5: preview blocked2 +remaining, but the raw first R plan sealed. Central cost validation now rejects +that exact unchanged position before source quoting and permits H-only. +Richese ordinary primary reserves debit only at real reveal. No early force/ +marker/history move or saved field; no future H or allied pool can fund the fee. +Artifacts preserve exact before/after source position and result. + +Three disjoint contributors supplied genuine native battle, marker and Revival +programmes. Parent reviewed all, consolidated marker slot matrices, removed +fixture/receipt/text echoes and compared physical card IDs rather than wording. +Native current-rate/prices, typed/unlimited/cap cases and real aftercare remain +observable; no bank, army, World face, phase, owner or receipt is assigned. +Ten setup, seven battle/aftercare, six marker and ten Revival Node programmes +exercise the changed paths. All card/identity/closed-deck staging is explicit. + +Five390px journeys/42 actions: short native marker H preserves unused R and ends +primary3/Tanks2; nine paid Suboids pay1N2E before7N return, ending native7N2E/ +reserves9/Tanks5 including2E. One board Cyborg alone is eligible for exchange +despite three Cyborgs in Tanks; recovery1/Face Dance returns8N1E to native Ix, +ending8N2E/reserves10/Tanks4 including2E and one dead winning leader. +Native Tleilaxu returns11: six free, invoice5, separate own-income1, reserves11/ +Tanks9/spice1, retaining unlimited instead of a seven cap. Original deployment, +Guild timing and phase openings remain. Native CHOAM pays its normal3-reserve +fee, retains17; original Auditor response/payment/inspection/market finishes +with the actual unused Chaumurky still owned by Guild. + +A wait for a transient Karama response timed out after native UI automatically +advanced to Face Dance. Restore390px before further interaction; no accepted +exchange was replayed. All accepted controls/final surfaces are390px, no root +overflow/app error. Owned host/tab/scaffolds removed. Artifacts live under +`/tmp/dune-native-world-*-proof.json`; actual rooms/DBs were never touched. + +Four policies finish28 battle programmes/2230 R-or-H proposals/112 continuations, +24 marker programmes/3050 proposals/120 continuations and40 useful native +Revival programmes/300 actions. Policy-only useful R holders are explicit +conserved-card staging. This is not full modes/strategy/calibration, broader +assurance or deployed evidence. +Frozen related21-file union passes251/251. Types/lint, build and actual prototype +help pass; existing deprecation, chunk-size and route-classification warnings +remain. The direct original marker3/reserves5 smoke again observes unchanged +first-seal rejection plus legal H-only. Remove contributed transient-window, +shipment-price and starting-wallet pins; inspect Auditor payment before later +Collection income, retaining cancellation, inspection and physical aftercare +assertions. No HTTP/database boundary or broad assurance campaign was run. + + ## 16 October 2026 — Classic World independent cards with phase modules Real fresh classic World+Tech/Stronghold lobbies rejected the blanket profile diff --git a/docs/RECRUITS_RUNTIME.md b/docs/RECRUITS_RUNTIME.md index b0774dc2..7a3c0df5 100644 --- a/docs/RECRUITS_RUNTIME.md +++ b/docs/RECRUITS_RUNTIME.md @@ -38,7 +38,8 @@ 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); 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. +Selected E1/E2 Worlds now use a [separate native no-phase-module contract](ECAZ_TREACHERY_RULES.md#native-selected-family-world-composition--17-october-2026); +other rosters and overlays remain separate. 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 316b56c7..5af3a222 100644 --- a/docs/REINFORCEMENTS_RUNTIME.md +++ b/docs/REINFORCEMENTS_RUNTIME.md @@ -3,9 +3,10 @@ The separate Ecaz Treachery Cards variant shuffles one physical Reinforcements card independently of choosing the Ecaz faction. Its bounded handler supports Basic/Advanced classic-faction tables, clean selected Ix and/or CHOAM native/base -rosters, and clean Ecaz/Moritani expansion tables. Fresh clean classic Homeworlds -use native sources below. Original Tech/Advanced Strongholds separately use the -[phase-module contract](ECAZ_TREACHERY_RULES.md#original-phase-module-composition--16-october-2026). +rosters, and clean Ecaz/Moritani expansion tables. Classic and selected E1/E2 +Worlds use native sources below; native selected-family Worlds add no other +modules. Classic base36 separately uses the +[phase-module contract](ECAZ_TREACHERY_RULES.md#classic-world-phase-module-composition--16-october-2026). The selected Advanced Occupy profile separately composes co-present armies. The holder may commit it in either Battle Plan card slot. It is neither a weapon nor a defense: Prescience of a category receives **None**, while the physical @@ -113,6 +114,14 @@ reserve-cap, token identity/history, support and fixed/variable losses stay. That earlier checkpoint did not admit Basic Richese. The clean selected-family composition above now admits it without changing this funding order. +Selected native Worlds now compose the same funding order. A real marker3/ +reserves5 owner preview correctly left2 and blocked, but the newly reachable +native-source API path accepted its first R plan. It now rejects unchanged +before source quotation and still permits H-only. Richese's ordinary primary +World pool debits at the actual reveal; the three-counter native fee remains +separate. See the [native World contract](ECAZ_TREACHERY_RULES.md#native-selected-family-world-composition--17-october-2026). + + ### Scoped proof @@ -198,7 +207,8 @@ 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. 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. +Selected native E1/E2 Worlds now use the [separate no-phase-module contract](ECAZ_TREACHERY_RULES.md#native-selected-family-world-composition--17-october-2026); +other rosters/overlays, public starts and save conversion remain gated. ### Sources, physical pool and original quote diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 2574498d..b2ec5d4c 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -666,8 +666,9 @@ 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 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) | +| Independent Treachery / original phase modules | Printed E3 variant independence permits unused Tech3–6 Basic/Advanced and/or Strongholds2–6 Advanced with clean classic/selected Ix/CHOAM native/base rosters. Classic base36 Worlds separately compose them; selected E1/E2 World phase modules remain gated. Original setup, delayed income and earned custody stay. R fee-before-Harass is provisional; returns are not free Revival/shipment and +2 is not supported force. Skills, Nexus, Discovery, E3 mixtures and unrelated previews remain 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) | +| Selected E1/E2 Worlds / independent cards | Use original selected38/50 native/base Basic/Advanced setup without other modules; classic base36 phase modules stay separate. Retain native typed/current-rate/unlimited allowances, native-only World declarations and every own-World H ban. R normal-first sources retain original face/traits and fee/dial disjointness. Native Richese validates3 AFTER projected marker materialization before a first seal, without moving it early. Only actual board Cyborg losses feed substitution; reserve fees are not duplicated as origins. Existing timing remains provisional, not a new FAQ. | [Source, scope and native funding](ECAZ_TREACHERY_RULES.md#native-selected-family-world-composition--17-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/game/card-presentation.ts b/game/card-presentation.ts index 4615afee..1fc41a57 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 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.' + ? 'Recruits has Revival controls in classic and selected Ix/CHOAM Basic/Advanced Worlds. Selected-family Worlds retain original native setup without other modules; base-deck classic Worlds may also retain Tech3+ and Advanced Strongholds2+. Double current native free rates within the ordinary seven-force total; native unlimited allowances, typed deposits and special-force caps 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. 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.', + ? 'Harass & Withdraw uses either battle-card slot and exact physical allocation after reveal, including selected Ix/CHOAM Worlds without other modules. Base-deck classic Worlds separately retain Tech3+ and Advanced Strongholds2+. Return actual native types: Advanced Emperor normal to Kaitain/Sardaukar to Salusa; other natives to their original primary World. Never withdraw on any own World. Original commitment/support stays. Own No-Field provisionally materializes first; Stone keeps its separate profile/allocation guards.' + : 'Reinforcements uses either slot, including selected Ix/CHOAM Basic/Advanced Worlds without other modules. Classic base36 Worlds separately retain Tech3+ and Advanced Strongholds2+. Pay three original own reserves normal-first for +2 normal score, not support or physical tokens. Exact native sources seal when ambiguous; an own-World fee cannot also fund its dial, while original traits/face stay. Own No-Field must leave three before payment, including native World reserves; future Harass cannot fund it. Remaining profile guards and cost/order interpretations stay provisional.', 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 eae268bb..4e6987e4 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -10697,31 +10697,39 @@ export function initializeSpiceBankerIncomeGameForAudit(state: Game): Game { if (advancedMoritaniLeaderSkillsProfile(initialized)) initializeMoritaniAssassinateState(initialized); return initialized; } -function classicEcazHomeworldProfile(g: Game): boolean { - return !!g.homeworlds && g.ecazTreachery === true && g.expansions.length === 0 && +/** Independent cards keep the actual selected base/E1/E2 physical families. */ +function independentCardRoster(g: Game): boolean { + if (g.expansions.length > 2 || + (g.expansions.length === 2 && g.expansions[0] === g.expansions[1]) || + g.expansions.some(expansion => expansion !== 'ix' && expansion !== 'choam')) return false; + return g.players.every(player => { + const family = faction(player.faction).expansion; + return family === 'base' || ((family === 'ix' || family === 'choam') && g.expansions.includes(family)); + }); +} +function ecazHomeworldProfile(g: Game): boolean { + return !!g.homeworlds && g.ecazTreachery === true && independentCardRoster(g) && g.players.length >= 2 && g.players.length <= 6 && - g.players.every(p => CLASSIC_FACTIONS[p.faction]) && !g.nexusCards && !g.leaderSkills && ((!g.techTokens && !g.strongholdCards) || independentCardModulesProfile(g)) && - !g.discoveryEnabled && !g.discoveries && !g.discoveryStash && !g.greatMaker && !g.sandtrout; + !g.discoveryEnabled && !g.discoveries && !g.discoveryStash && !g.greatMaker && !g.sandtrout && + !g.ecazOccupyPreview && !g.semutaPreview && !g.kullPreview && !g.nexusKullPreview && + !g.richeseBetrayalPreview && !g.guildBetrayalPreview && !g.moritaniAssassinatePreview && + !g.spiceBankerIncomePreview && !g.nexusIxianReplacementPreview && + !g.nexusIxianBetrayalPreview && !g.nexusHarkonnenBetrayalPreview; } /** Independent cards compose original phase modules, not unrelated previews. */ function independentCardModulesProfile(g: Game): boolean { - if (!g.ecazTreachery || (!g.techTokens && !g.strongholdCards) || + if (!g.ecazTreachery || (!g.techTokens && !g.strongholdCards) || !independentCardRoster(g) || g.players.length < (g.techTokens ? 3 : 2) || g.players.length > 6 || - (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.strongholdCards && !g.advanced) || (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 || g.spiceBankerIncomePreview || g.nexusIxianReplacementPreview || g.nexusIxianBetrayalPreview || g.nexusHarkonnenBetrayalPreview) return false; - return g.players.every(player => { - const family = faction(player.faction).expansion; - return family === 'base' || ((family === 'ix' || family === 'choam') && g.expansions.includes(family)); - }); + return true; } /** Gated development setup for the independent three-card Ecaz variant. */ export function initializeEcazTreacheryGameForAudit(state: Game): Game { @@ -10729,8 +10737,8 @@ export function initializeEcazTreacheryGameForAudit(state: Game): Game { const g = structuredClone(state); g.ecazTreachery = true; const homeworlds = !!g.homeworlds; - requireRule(!homeworlds || classicEcazHomeworldProfile(g), - 'Independent cards with Homeworlds require a fresh classic roster with only supported original Tech or Advanced Strongholds.'); + requireRule(!homeworlds || ecazHomeworldProfile(g), + 'Independent cards with Homeworlds need a fresh classic or selected Ix/CHOAM roster; phase modules remain classic-only.'); 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 { @@ -13777,7 +13785,7 @@ function ecazTreacheryIntegrity(g: Game) { function recruitsModeSupported(g: Game) { const modules = independentCardModulesProfile(g); return ( - (!g.homeworlds || classicEcazHomeworldProfile(g)) && + (!g.homeworlds || ecazHomeworldProfile(g)) && !g.nexusCards && !g.leaderSkills && (!g.techTokens || modules) && @@ -13800,13 +13808,9 @@ function combinedCardBattleBlock(g: Game): string | null { } function reinforcementsModeSupported(g: Game) { const pairedEcaz = g.expansions.length === 1 && g.expansions[0] === 'ecaz'; - const nativeFamilies = !g.homeworlds && !g.discoveries && !g.discoveryStash && !g.greatMaker && + const nativeFamilies = !g.discoveries && !g.discoveryStash && !g.greatMaker && !g.semutaPreview && !g.kullPreview && !g.ecazOccupyPreview && - g.expansions.length > 0 && g.expansions.every(expansion => expansion === 'ix' || expansion === 'choam') && - g.players.every(player => { - const family = faction(player.faction).expansion; - return family === 'base' || ((family === 'ix' || family === 'choam') && g.expansions.includes(family)); - }); + g.expansions.length > 0 && independentCardRoster(g); return !!g.ecazTreachery && recruitsModeSupported(g) && !g.sandtrout && (nativeFamilies || (g.ecazOccupyPreview === true && ecazOccupyCompositionSupported(g)) || ((g.expansions.length === 0 || pairedEcaz) && g.players.every(player => { @@ -13827,12 +13831,15 @@ function currentReinforcementsCost(g: Game, p: Player, requireRule(selections === undefined || g.homeworlds, 'Native Reinforcements sources require the original Homeworld module.'); try { + const materializes = prospectiveNoFieldForces(p, g.battle?.territory ?? ''); if (g.homeworlds) { requireRule(g.homeworlds.custody, 'Reinforcements needs its original native reserve custody.'); + // Richese materializes ordinary primary-World reserves before this fee. + if (materializes) + quoteReinforcements(p.reserves - materializes - (p.elites?.reserves ?? 0), p.elites?.reserves ?? 0); return homeworldRule(() => quoteReinforcementsHomeworldCost( homeworldContext(g), g.homeworlds!.custody!, p.id, selections)); } - const materializes = prospectiveNoFieldForces(p, g.battle?.territory ?? ''); return quoteReinforcements(p.reserves - materializes - (p.elites?.reserves ?? 0), p.elites?.reserves ?? 0); } catch (error) { if (error instanceof ReinforcementsError) throw new RuleError(error.message); diff --git a/game/reference.ts b/game/reference.ts index 1a30eb3f..9bb6d8ec 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -4983,6 +4983,7 @@ export const RULE_TOPICS: RuleTopic[] = [ '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.', '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.', + 'Selected Ix/CHOAM native/base Basic/Advanced Worlds now use original38/50 independent-card setup without other modules. Current native bonuses, typed deposits and accepted paid/free classifications stay. Ix keeps its multiple-Cyborg allowance; Tleilaxu and CHOAM remain unlimited rather than being reduced to seven. Other card/overlay/public-start gates remain.', ], related: ['revival', 'homeworlds', 'ecaz-modules', 'expansion-faction-games', 'implementation-checklist'], checklist: [ @@ -4990,7 +4991,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: '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'] }, + { area: 'Verification', status: 'Partial', detail: 'Earlier classic World/phase evidence stays dated in linked contracts. Selected native Worlds add real typed deposits/prices, current population crossings, prior-paid reopening, unlimited Tleilaxu/CHOAM returns and ordinary Richese/Imperial elite caps. One mobile11-force revival/9 actions and40 useful four-policy programmes/300 actions reach legal shipment. Policy card holders are explicit conserved staging. Complete modes/calibration remain open.', evidence: ['tests/native-world-recruits.test.ts', 'tests/fixture-native-world-recruits.ts', '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,9 +5007,9 @@ 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.', - '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.', + 'Classic and selected Ix/CHOAM Basic/Advanced Worlds retain exact native normal-first sources, sealed when ambiguous; unique sources may derive. Own-World fees cannot also fund dial/casualties; original face/support stays, including explosion thresholds. Phase modules remain classic base36 only. Native-only World Traitors, typed Tanks and fee-before-Harass stay 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.', + 'Selected Ix/CHOAM Worlds add no other modules; original38/50 setup and native stock remain. Cyborg substitution and Face Dance use their actual physical pools, never a duplicate fee origin. Native Richese must retain three World reserves AFTER its own projected No-Field reveal before R seals; rejection moves no counter or marker. Without Worlds, original phase-module and admitted Stone compositions remain separate.', ], related: ['battle-cards', 'battle', 'homeworlds', 'card-recruits', 'card-harass-withdraw', 'card-truthtrance', 'implementation-checklist'], checklist: [ @@ -5016,7 +5017,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: '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'] }, + { area: 'Verification', status: 'Partial', detail: 'Earlier classic/family/phase evidence stays dated. Native Worlds add1N2E fees before normal returns, own-Ix fee/dial disjointness/H ban, one board-Cyborg exchange versus fee casualties, native Face Dance/Auditor and marker funding/caps. Four mobile battle/marker journeys33 actions;52 four-policy programmes232 continuations5280 R-or-H proposals preserve normal outcomes. Before/after raw marker3/reserves5 proves unchanged first-seal rejection. No complete-mode/calibration/deployed claim.', evidence: ['tests/native-world-battle.test.ts', 'tests/fixture-native-world-battle.ts', 'tests/native-world-marker.test.ts', 'tests/fixture-native-world-marker.ts', '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'] }, ], }, { @@ -5035,7 +5036,7 @@ export const RULE_TOPICS: RuleTopic[] = [ 'In explicitly selected Advanced Occupy, only the actual card user’s undialed forces return. An Ecaz user preserves its original mandatory fixed count at free full strength; an allied user uses its native variable commitment, while canceled Occupy uses its selected lead’s ordinary own pool. Returned reserves remain outside losses and Face Dance. Basic/unprofiled co-side, public starts and full acceptance remain gated.', '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.', + 'Classic and selected Ix/CHOAM Basic/Advanced Worlds may choose the independent cards before original setup. Harass works on Arrakis or a foreign World, never an own World. Return actual undialed board/visitor types through native custody: normal/special to the original primary World except Advanced Emperor normal to Kaitain and Sardaukar to Salusa. Original commitment stays across thresholds; native-only World Traitors/Face Dancers, late defense/explosion and disposal stay. Native Worlds add no phase modules.', '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'], @@ -5044,7 +5045,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: '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'] }, + { area: 'Verification', status: 'Partial', detail: 'Earlier World/phase/No-Field/Stone proofs stay dated. Selected native Worlds add exact primary typed deposits, normal-first R payment before7 Suboids return, one eligible board-Cyborg exchange and native-only World Face Dance/Auditor. Four mobile battle/marker journeys33 actions;52 four-policy programmes232 continuations5280 R-or-H proposals reach original queues. Unfunded R stays unspent under legal H fallback. No complete-mode/deployed/calibration claim.', evidence: ['tests/native-world-battle.test.ts', 'tests/fixture-native-world-battle.ts', 'tests/native-world-marker.test.ts', 'tests/fixture-native-world-marker.ts', '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-native-world-battle.ts b/tests/fixture-native-world-battle.ts new file mode 100644 index 00000000..c6c8a8aa --- /dev/null +++ b/tests/fixture-native-world-battle.ts @@ -0,0 +1,214 @@ +import assert from 'node:assert/strict'; +import { applyAction, viewGame, type Game, type Player } from '../game/engine'; +import { isAuditorLeader } from '../game/cards'; +import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game'; +import { homeworldForceGroups, type HomeworldForces } from '../game/homeworld-custody'; +import { nativeShipmentSources } from '../game/homeworld-options'; +import { homeworldShipmentChoice } from '../game/homeworld-shipment-options'; +import { REINFORCEMENTS_CARD } from '../game/reinforcements'; +import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw'; +import { createIndependentCardModulesFixture } from './fixture-independent-card-modules'; +import type { NativeReserveSelections } from '../game/homeworld-native-reserves'; +import { + advanceHarassHomeworld, harassHomeworldClean, nextHarassHomeworldStep, + type HarassHomeworldStep, +} from './fixture-harass-homeworld'; + +export { REINFORCEMENTS_CARD, HARASS_WITHDRAW_CARD }; +export type NativeWorldBattleStep = HarassHomeworldStep; +export type NativeWorldBattleOptions = { + advanced?: boolean; + route?: 'ix-visitor' | 'ix-native' | 'choam-native'; + /** Actual first-turn export, not a reserve assignment. */ + exportNormal?: number; + exportElite?: number; + ownerDial?: number; + harass?: boolean; + outcome?: 'win' | 'loss'; + /** Exchange an original unrevealed Face Dancer for the chosen leader. */ + faceDancer?: boolean; +}; +export type NativeWorldBattleShipment = { before: Game; step: NativeWorldBattleStep; cost: number; after: Game }; +export type NativeWorldBattleFixture = { + initial: Game; afterSetup: Game; game: Game; options: NativeWorldBattleOptions; + route: 'ix-visitor' | 'ix-native' | 'choam-native'; + owner: string; opponent: string; native: string; location: string; + shipments: NativeWorldBattleShipment[]; + snapshots: { afterSetup: Game; beforeBattle: Game; plans: Game }; + beforeBattle: Game; battleStep: NativeWorldBattleStep; + planActions: [NativeWorldBattleStep, NativeWorldBattleStep]; + sources: NativeReserveSelections; returns: Record; + nativeSteps: NativeWorldBattleStep[]; staging: string[]; +}; +export const nativeWorldBattlePlayer = (game: Game, actor: string): Player => { + const player = game.players.find(p => p.id === actor); assert.ok(player); return player; +}; +export const nativeWorldBattlePool = (game: Game, actor: string, location: string): HomeworldForces => { + const world = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!).find(w => w.id === location); + assert.ok(world); return { ...(world.forces[actor] ?? { normal: 0, elite: 0 }) }; +}; +const physicalCards = (game: Game) => [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand), + ...(game.auction?.cards.slice(game.auction.index) ?? [])].map(c => c.id).sort(); +const physicalIdentities = (game: Game) => [...(game.traitorReserve ?? []), ...game.players.flatMap(p => + [...p.traitors, ...p.traitorChoices, ...(p.faceDancers ?? []).map(d => d.leader)])].sort(); + +/** Native reserves already represent the primary World: never count it twice. + * Ix owns seven Cyborgs in Basic as well as Advanced, not Emperor's five. */ +export function assertNativeWorldBattleInventory(game: Game, original: Game): void { + homeworldGameIntegrity(game); + const worlds = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!); + for (const p of game.players) { + const foreign = worlds.filter(w => w.native !== p.id).map(w => w.forces[p.id] ?? { normal: 0, elite: 0 }); + assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((n, v) => n + v, 0) + + foreign.reduce((n, f) => n + f.normal + f.elite, 0), 20, p.faction); + if (p.elites) assert.equal(p.elites.reserves + p.elites.tanks + + Object.values(p.elites.forces).reduce((n, v) => n + v, 0) + foreign.reduce((n, f) => n + f.elite, 0), + p.faction === 'ixians' ? 7 : p.faction === 'emperor' ? 5 : 3); + } + assert.deepEqual(physicalCards(game), physicalCards(original)); + assert.deepEqual(physicalIdentities(game), physicalIdentities(original)); +} + +function conserveCard(game: Game, actor: string, id: string, staging: string[]): void { + const owner = nativeWorldBattlePlayer(game, actor); + if (owner.hand.some(c => c.id === id)) return; + const at = game.deck.findIndex(c => c.id === id); assert.ok(at >= 0); + owner.hand.push(game.deck.splice(at, 1)[0]); + staging.push(`Conserved original ${id}: undealt deck → ${actor}; not a purchase.`); +} +function conserveDancer(game: Game, identity: string, staging: string[]): void { + const tleilaxu = nativeWorldBattlePlayer(game, 'tleilaxu'); + if (tleilaxu.faceDancers!.some(d => d.leader === identity)) return; + const dancer = tleilaxu.faceDancers!.find(d => !d.revealed); assert.ok(dancer); + const returned = dancer.leader; + const at = game.traitorReserve!.indexOf(identity); + const donor = game.players.find(p => p.traitors.includes(identity)); + assert.ok(at >= 0 || donor, 'Use an original physical identity, never mint a Face Dancer'); + if (at >= 0) game.traitorReserve!.splice(at, 1, returned); + else donor!.traitors[donor!.traitors.indexOf(identity)] = returned; + dancer.leader = identity; + staging.push(`Conserved Face Dancer ${identity}: ${donor?.id ?? 'original reserve'} exchanged with Tleilaxu's ${returned}.`); +} + +/** Real native setup, paid exports/invasions and original battle preparation. + * Only unopened Spice order, physical hands and an optional identity exchange + * are staged. All armies, banks, World faces, phase and receipts stay native. */ +export function createNativeWorldBattleFixture(options: NativeWorldBattleOptions = {}): NativeWorldBattleFixture { + const route = options.route ?? 'ix-visitor'; + const advanced = options.advanced ?? false; + const setup = createIndependentCardModulesFixture({ advanced, homeworlds: true, tech: false, strongholds: false, + factions: route === 'choam-native' ? ['choam', 'guild', 'tleilaxu', 'ixians'] : ['ixians', 'tleilaxu', 'guild'], + expansions: route === 'choam-native' ? ['ix', 'choam'] : ['ix'] }); + let game = setup.game; + const afterSetup = structuredClone(game), nativeSteps: NativeWorldBattleStep[] = [...setup.steps]; + const staging: string[] = []; + for (let i = 0; i < (advanced ? 4 : 2); i++) { + const at = game.spiceDeck.findIndex((c, index) => index >= i && 'territory' in c && !c.discovery); + assert.ok(at >= i); game.spiceDeck.splice(i, 0, game.spiceDeck.splice(at, 1)[0]); + } + staging.push('Position original unopened non-worm Spice Cards for turn one; original Storm and later draws remain untouched.'); + const act = (step: NativeWorldBattleStep) => { + nativeSteps.push(structuredClone(step)); game = applyAction(game, step.actor, step.action); + }; + const reach = (until: (g: Game) => boolean) => { game = advanceHarassHomeworld(game, until, nativeSteps); }; + const shipments: NativeWorldBattleShipment[] = []; + const ship = (actor: string, destination: string, normal: number, elite: number) => { + reach(g => g.phase === 5 && g.active === actor && harassHomeworldClean(g)); + const before = structuredClone(game), view = viewGame(game, actor); + const sources = nativeShipmentSources(view, normal + elite, elite); assert.ok(sources); + const choice = homeworldShipmentChoice(view, destination, + Object.fromEntries(Object.entries(sources).filter(([, f]) => f.normal + f.elite > 0))); + assert.ok(choice.action, choice.blocked ?? 'Original paid native shipment must be available'); + const step = { actor, action: choice.action }; act(step); + reach(g => !g.pendingHomeworldShipment && !g.pendingShipment && harassHomeworldClean(g)); + shipments.push({ before, step, cost: choice.cost, after: structuredClone(game) }); + }; + const owner = route === 'choam-native' ? 'choam' : 'ixians'; + const opponent = route === 'ix-visitor' ? 'tleilaxu' : 'guild'; + const native = route === 'ix-visitor' ? opponent : owner; + const location = `homeworld:${native}`; + // Advanced Guild chooses its real early-shipment window before Ixians. + if (route === 'ix-native' && advanced) ship('guild', location, 3, 0); + if (route !== 'choam-native') ship('ixians', route === 'ix-visitor' ? location : 'homeworld:tleilaxu', + options.exportNormal ?? 9, options.exportElite ?? 0); + if (route === 'choam-native' || route === 'ix-native' && !advanced) ship('guild', location, 3, 0); + reach(g => g.phase === 6 && !!g.active && harassHomeworldClean(g)); + // Preserve every original card, including Ix setup draws and CHOAM stock. + for (const p of game.players) game.deck.push(...p.hand.splice(0)); + staging.push('Return all original hands to unopened stock before conserved card selection; no native stock is omitted.'); + conserveCard(game, owner, REINFORCEMENTS_CARD, staging); + const harass = options.harass ?? route === 'ix-visitor'; + if (harass || route !== 'ix-visitor') conserveCard(game, owner, HARASS_WITHDRAW_CARD, staging); + if (route === 'choam-native') { + const unused = game.deck.find(c => c.id !== REINFORCEMENTS_CARD && c.id !== HARASS_WITHDRAW_CARD); + assert.ok(unused); conserveCard(game, opponent, unused.id, staging); + } + const living = (actor: string) => nativeWorldBattlePlayer(game, actor).leaders.filter(l => !l.dead && !l.usedAt && !isAuditorLeader(l)) + .sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id)); + const win = options.outcome === 'win'; + const ownLeader = route === 'choam-native' && advanced ? nativeWorldBattlePlayer(game, owner).leaders.find(isAuditorLeader)! + : win ? living(owner).at(-1)! : living(owner)[0]; + const otherLeader = win ? living(opponent)[0] : living(opponent).at(-1)!; + assert.ok(ownLeader && otherLeader); + if (options.faceDancer) { + conserveDancer(game, ownLeader.id, staging); + } + const beforeBattle = structuredClone(game); + const choice = viewGame(game, game.active!).battleChoices.find(c => c.territory === location); assert.ok(choice); + const battleStep: NativeWorldBattleStep = { actor: choice.chooser, action: { type: 'chooseBattle', territory: location, + 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 preview = viewGame(game, owner).battle!.reinforcements; assert.ok(preview && !preview.blocked); + const positiveSources = preview.homeworldSources?.[0]; assert.ok(positiveSources); + const dial = options.ownerDial ?? (route === 'choam-native' ? 0 : route === 'ix-native' ? 2 : 1); + const support = 0; + const planActions: [NativeWorldBattleStep, NativeWorldBattleStep] = [ + { actor: owner, action: { type: 'battlePlan', dial, support, leader: ownLeader.id, + weapon: REINFORCEMENTS_CARD, defense: harass ? HARASS_WITHDRAW_CARD : null, reinforcementsSources: positiveSources } }, + { actor: opponent, action: { type: 'battlePlan', dial: win ? 0 : route === 'ix-visitor' ? 3 : 0, + support: 0, leader: otherLeader.id } }, + ]; + const returns: Record = harass + ? { [location]: { normal: (options.exportNormal ?? 9) - 2, elite: options.exportElite ?? 0 } } : {}; + assertNativeWorldBattleInventory(game, afterSetup); + return { initial: setup.initial, afterSetup, game, options, route, owner, opponent, native, location, + shipments, snapshots: { afterSetup, beforeBattle, plans: structuredClone(game) }, beforeBattle, battleStep, + planActions, sources: positiveSources, returns, nativeSteps, staging }; +} + +/** Stop at a requested original aftercare decision rather than letting a policy + * silently decline the native Face Dance, Auditor or Cyborg exchange. */ +export function advanceNativeWorldBattle(game: Game, until: (g: Game) => boolean, + steps: NativeWorldBattleStep[] = [], options: { returns?: Record; callers?: string[]; + casualties?: HomeworldForces } = {}): Game { + for (let n = 0; n < 300; n++) { + if (until(game)) return game; + assert.ok(game.battle || game.homeworldBattleLoss || game.decision || game.response || game.phaseOpening, + 'The original battle finished without the requested aftercare decision'); + let step: NativeWorldBattleStep | null; + if (!game.response && !game.phaseOpening && game.decision?.kind === 'harassWithdraw') { + assert.ok(options.returns); step = { actor: game.decision.player, + action: { type: 'decision', event: game.decision.event, returns: options.returns } }; + } else if (!game.response && !game.phaseOpening && game.decision?.kind === 'battleLosses' && options.casualties) { + const d = game.decision; + const choice = d.options.findIndex(f => f.normal === options.casualties!.normal && f.elite === options.casualties!.elite); + assert.ok(choice >= 0, 'Choose a genuine native typed casualty option'); + step = { actor: d.player, action: { type: 'decision', choice } }; + } else step = nextHarassHomeworldStep(game); + assert.ok(step, 'Seal actual human plans before continuing'); + if (step.action.type === 'traitorCall') step.action.call = options.callers?.includes(step.actor) ?? false; + steps.push(structuredClone(step)); game = applyAction(game, step.actor, step.action); + } + throw Error('Native battle programme did not reach the requested aftercare boundary'); +} +export function revealNativeWorldBattle(fixture: NativeWorldBattleFixture): Game { + let game = structuredClone(fixture.game); + for (const step of fixture.planActions) { + game = applyAction(game, step.actor, step.action); + game = advanceNativeWorldBattle(game, g => harassHomeworldClean(g) && !!g.battle && + (!g.battle.preparation && g.battle.preLeader?.closed !== false), [], { returns: fixture.returns }); + } + assert.ok(game.battle?.revealed); return game; +} +export const nativeWorldBattleFinished = (g: Game) => !g.battle && !g.homeworldBattleLoss && harassHomeworldClean(g); diff --git a/tests/fixture-native-world-marker.ts b/tests/fixture-native-world-marker.ts new file mode 100644 index 00000000..c8cf1090 --- /dev/null +++ b/tests/fixture-native-world-marker.ts @@ -0,0 +1,97 @@ +import assert from 'node:assert/strict'; +import { viewGame, type Game } from '../game/engine'; +import { forceInventory } from '../game/force-inventory'; +import type { NativeReserveSelections } from '../game/homeworld-native-reserves'; +import { + createIndependentCardModulesFixture, type IndependentCardModulesStep, +} from './fixture-independent-card-modules'; +import { + createRicheseReinforcementsFixture, RICHESE_HARASS, RICHESE_REINFORCEMENTS, + type RicheseReinforcementsFixture, type RicheseReinforcementsOptions, + type RicheseReinforcementsStep, +} from './fixture-richese-reinforcements'; + +export type NativeWorldMarkerOptions = Omit; +export type NativeWorldMarkerFixture = RicheseReinforcementsFixture & { + /** Ready but undealt: the original initializer seam for controls/policies. */ + readyLobby: Game; + readySteps: IndependentCardModulesStep[]; + actors: { owner: string; opponent: string; observer: string }; + source: { + homeworld: string; + reservesBeforeReveal: number; + materializes: number; + reservesAfterReveal: number; + feeChoices: NativeReserveSelections[] | undefined; + }; +}; + +/** Genuine selected-family World programme. Only the original initializer's + * randomness is scoped. Basic low budgets come from two reveals, real Spice + * Blow collection and an ordinary paid shipment over four turns. Advanced uses + * its funded turn-one route, never a staged low budget or frozen later Storm. + * The inherited staging log explicitly labels conserved physical card moves; + * none of those moves is represented as a natural purchase. */ +export function createNativeWorldMarkerFixture( + options: NativeWorldMarkerOptions = {}, +): NativeWorldMarkerFixture { + const family = options.family ?? 'choam'; + const selected = createIndependentCardModulesFixture({ + advanced: options.advanced ?? false, + homeworlds: true, + tech: false, + strongholds: false, + expansions: family === 'choam' ? ['choam'] : ['ix', 'choam'], + factions: family === 'choam' ? ['richese', 'guild', 'atreides'] : ['richese', 'ixians', 'atreides'], + }); + const readyLobby = selected.initial; + const fixture = createRicheseReinforcementsFixture({ ...options, family, initial: readyLobby }); + const view = viewGame(fixture.game, fixture.richese); + const homeworld = view.homeworlds?.worlds?.find(home => home.native === fixture.richese && !home.secondary); + const preview = view.battle!.reinforcements!; + assert.ok(homeworld && preview.noField); + return { + ...fixture, + readyLobby, + readySteps: selected.steps.filter(step => step.action.type === 'ready'), + actors: { owner: fixture.richese, opponent: fixture.opponent, observer: fixture.observer }, + source: { + homeworld: homeworld.id, + reservesBeforeReveal: homeworld.forces[fixture.richese].normal, + materializes: preview.noField.materializes, + reservesAfterReveal: preview.noField.reservesAfterReveal, + feeChoices: structuredClone(preview.homeworldSources), + }, + }; +} + +/** Original public inventory counts native Worlds within reserves exactly once, + * keeps foreign visitors separate and preserves Ixian elite identity. */ +export function nativeWorldMarkerInventory(game: Game, actor: string) { + const view = viewGame(game, actor); + const player = view.players.find(seat => seat.id === actor); + const worlds = view.homeworlds?.worlds; + assert.ok(player && worlds); + const inventory = forceInventory(player, worlds); + assert.ok(inventory, `Original native force inventory is inconsistent for ${actor}`); + return inventory; +} + +/** Both independent machines may occupy either actual battle-plan slot. For a + * combined plan slot selects R's slot; H occupies the other. The fee allocation + * is the owner's real preview quote, never an inferred native source split. */ +export function nativeWorldMarkerPlan(fixture: NativeWorldMarkerFixture, + mode: 'reinforcements' | 'both' | 'harass' | 'plain' = 'reinforcements', + slot: 'weapon' | 'defense' = 'weapon'): RicheseReinforcementsStep { + const step = structuredClone(fixture.plans.plain); + step.action.weapon = null; + step.action.defense = null; + if (mode === 'reinforcements' || mode === 'both') { + step.action[slot] = RICHESE_REINFORCEMENTS; + const sources = fixture.source.feeChoices?.[0]; + if (sources) step.action.reinforcementsSources = structuredClone(sources); + } + if (mode === 'harass') step.action[slot] = RICHESE_HARASS; + if (mode === 'both') step.action[slot === 'weapon' ? 'defense' : 'weapon'] = RICHESE_HARASS; + return step; +} diff --git a/tests/fixture-native-world-recruits.ts b/tests/fixture-native-world-recruits.ts new file mode 100644 index 00000000..ede4990b --- /dev/null +++ b/tests/fixture-native-world-recruits.ts @@ -0,0 +1,193 @@ +import assert from 'node:assert/strict'; +import { applyAction, viewGame, type Action, type Game } from '../game/engine'; +import { combatArmy } from '../game/combat-location'; +import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game'; +import { homeworldForceGroups } from '../game/homeworld-custody'; +import { nativeShipmentSources } from '../game/homeworld-options'; +import { homeworldShipmentChoice } from '../game/homeworld-shipment-options'; +import { forceRevivalQuote } from '../game/revival'; +import { RECRUITS_CARD_ID } from '../game/recruits'; +import { createIndependentCardModulesFixture } from './fixture-independent-card-modules'; +import { nextHarassHomeworldStep } from './fixture-harass-homeworld'; +import { takeHarassCard } from './fixture-harass-withdraw'; + +export type NativeWorldRecruitsFaction = 'ixians' | 'tleilaxu' | 'choam' | 'richese'; +export type NativeWorldRecruitsStep = { actor: string; action: Action }; +export type NativeWorldRecruitsFixture = { + initial: Game; + afterSetup: Game; + beforeShipment: Game; + shipmentStep: NativeWorldRecruitsStep; + shipmentPrice: number; + afterShipment: Game; + beforeBattle: Game; + battleStep: NativeWorldRecruitsStep; + planSteps: NativeWorldRecruitsStep[]; + afterBattle: Game; + /** Quiet original second-turn Revival, with the conserved card in owner's hand. */ + game: Game; + owner: string; + native: string; + visitor: string; + location: string; + steps: NativeWorldRecruitsStep[]; + staging: string[]; +}; + +export function nativeWorldRecruitsPlayer(game: Game, actor: string) { + const player = game.players.find(p => p.id === actor); + assert.ok(player, `Missing native seat ${actor}`); + return player; +} +export const nativeWorldRecruitsClean = (game: Game) => + !game.response && !game.phaseOpening && !game.decision && !game.pendingTreacheryDiscard; + +/** Original native choices, never a population, wallet, phase or saved receipt edit. + * An unrequested Face Dancer is declined; every other window keeps its original + * continuation and the whole remaining programme is driven by it. */ +export function nextNativeWorldRecruitsStep(game: Game): NativeWorldRecruitsStep | null { + if (!game.response && !game.phaseOpening && game.decision?.kind === 'faceDance') + return { actor: game.decision.player, action: { type: 'decision', reveal: false } }; + return nextHarassHomeworldStep(game); +} + +export function advanceNativeWorldRecruits(game: Game, until: (state: Game) => boolean, + steps: NativeWorldRecruitsStep[] = []): Game { + for (let n = 0; n < 1800; n++) { + if (until(game)) return game; + assert.notEqual(game.status, 'finished', 'Native revival programme must reach its requested boundary'); + const step = nextNativeWorldRecruitsStep(game); + assert.ok(step, 'Supply actual human battle plans before continuing'); + steps.push(structuredClone(step)); + game = applyAction(game, step.actor, step.action); + } + throw Error('Native World Recruits programme did not reach its boundary'); +} + +/** Native World stock is already included in aggregate reserves; foreign World + * visitors are separate. Ix has seven physical cyborgs, not Emperor's five. */ +export function assertNativeWorldRecruitsInventory(game: Game): void { + homeworldGameIntegrity(game); + const homes = homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!); + for (const player of game.players) { + const visitors = homes.filter(home => home.native !== player.id) + .map(home => home.forces[player.id] ?? { normal: 0, elite: 0 }); + assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((a, b) => a + b, 0) + + visitors.reduce((sum, group) => sum + group.normal + group.elite, 0), 20, player.faction); + if (player.elites) assert.equal(player.elites.reserves + player.elites.tanks + + Object.values(player.elites.forces).reduce((a, b) => a + b, 0) + + visitors.reduce((sum, group) => sum + group.elite, 0), player.faction === 'ixians' ? 7 : 5); + } +} + +/** An Emperor pays to invade the actual native World. Unarmed original leaders + * let the defender lose its whole home army without shipping it or spending its + * wallet. The optional all-Sardaukar dial creates two real Imperial casualties + * for the ordinary elite cap. No stock is fabricated/reset. */ +export function createNativeWorldRecruitsFixture(options: { + advanced?: boolean; + faction?: NativeWorldRecruitsFaction; + imperialCasualties?: boolean; +} = {}): NativeWorldRecruitsFixture { + const native = options.faction ?? 'ixians'; + const setup = createIndependentCardModulesFixture({ advanced: options.advanced ?? false, + homeworlds: true, tech: false, strongholds: false, factions: ['emperor', native, 'guild'], + expansions: native === 'ixians' || native === 'tleilaxu' ? ['ix'] : ['choam'] }); + let game = setup.game; + const afterSetup = structuredClone(game), steps = [...setup.steps]; + assert.equal(setup.initial.status, 'lobby'); + assert.ok(setup.initial.homeworlds, 'The undealt ready lobby must have Homeworlds selected'); + assert.deepEqual(game.expansions, native === 'ixians' || native === 'tleilaxu' ? ['ix'] : ['choam']); + assert.equal(game.status, 'playing'); + for (const [name, value] of [['Tech Tokens', game.techTokens], ['Stronghold Cards', game.strongholdCards], + ['Nexus Cards', game.nexusCards], ['Leader Skills', game.leaderSkills], + ['Discovery', game.discoveryEnabled]] as const) + assert.equal(value, undefined, `${name} must stay outside a native World Recruits programme`); + const visitor = 'emperor', owner = visitor; + const worlds = viewGame(game, native).homeworlds?.worlds; + assert.ok(worlds, 'A native World programme needs its projected custody view'); + const home = worlds.find(w => w.native === native && !w.secondary); + assert.ok(home); + const location = home.id; + const staging = ['Return original setup hands to the unopened Treachery deck; no weapon or defense is used.']; + for (const player of game.players) { game.deck.push(...player.hand); player.hand = []; } + for (let i = 0; i < (game.advanced ? 4 : 2); i++) { + const at = game.spiceDeck.findIndex((card, index) => index >= i && 'territory' in card && !card.discovery); + assert.ok(at >= 0); + game.spiceDeck.splice(i, 0, game.spiceDeck.splice(at, 1)[0]); + } + staging.push('Position original unopened first-Blow non-worm cards; later Storm and all counters remain native.'); + const act = (actor: string, action: Action) => { + steps.push({ actor, action: structuredClone(action) }); + game = applyAction(game, actor, action); + }; + game = advanceNativeWorldRecruits(game, + g => g.phase === 5 && g.active === visitor && nativeWorldRecruitsClean(g), steps); + const beforeShipment = structuredClone(game); + const amount = options.imperialCasualties ? 2 : 1; + const sources = nativeShipmentSources(viewGame(game, visitor), amount, options.imperialCasualties ? 2 : 0); + assert.ok(sources); + const choice = homeworldShipmentChoice(viewGame(game, visitor), location, + Object.fromEntries(Object.entries(sources).filter(([, group]) => group.normal + group.elite > 0))); + assert.ok(choice.action, choice.blocked ?? 'Original typed invasion must be legal'); + assert.equal(choice.cost, amount); + const shipmentStep = { actor: visitor, action: choice.action }; + act(shipmentStep.actor, shipmentStep.action); + game = advanceNativeWorldRecruits(game, + g => !g.pendingHomeworldShipment && !g.pendingShipment && nativeWorldRecruitsClean(g), steps); + const afterShipment = structuredClone(game); + game = advanceNativeWorldRecruits(game, g => g.phase === 6 && nativeWorldRecruitsClean(g) && !!g.active, steps); + const beforeBattle = structuredClone(game); + const battleChoice = viewGame(game, game.active!).battleChoices.find(c => c.territory === location); + assert.ok(battleChoice); + const battleStep: NativeWorldRecruitsStep = { actor: battleChoice.chooser, + action: { type: 'chooseBattle', territory: location, + target: battleChoice.attacker === battleChoice.chooser ? battleChoice.defender : battleChoice.attacker } }; + act(battleStep.actor, battleStep.action); + const planSteps: NativeWorldRecruitsStep[] = []; + for (const actor of [visitor, native]) { + game = advanceNativeWorldRecruits(game, g => !!g.battle && nativeWorldRecruitsClean(g) && + !g.battle.preparation && g.battle.preLeader?.closed !== false, steps); + const leaders = nativeWorldRecruitsPlayer(game, actor).leaders.filter(l => !l.dead && !l.usedAt) + .sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id)); + const leader = actor === visitor ? leaders.at(-1) : leaders[0]; + assert.ok(leader); + const step: NativeWorldRecruitsStep = { actor, + action: { type: 'battlePlan', leader: leader.id, + dial: actor === visitor && options.imperialCasualties ? (game.advanced ? 4 : 2) : 0, support: 0 } }; + planSteps.push(step); act(step.actor, step.action); + } + game = advanceNativeWorldRecruits(game, + g => !g.battle && !g.homeworldBattleLoss && nativeWorldRecruitsClean(g), steps); + const afterBattle = structuredClone(game); + assert.equal(nativeWorldRecruitsPlayer(game, native).tanks, native === 'ixians' ? 14 : 20); + game = advanceNativeWorldRecruits(game, + g => g.turn === 2 && g.phase === 4 && nativeWorldRecruitsClean(g), steps); + takeHarassCard(game, owner, RECRUITS_CARD_ID); + staging.push(`Conserve the existing physical Recruits to ${owner} at the real second Revival; this is not a purchase.`); + assertNativeWorldRecruitsInventory(game); + return { initial: setup.initial, afterSetup, beforeShipment, shipmentStep, shipmentPrice: choice.cost, + afterShipment, beforeBattle, battleStep, planSteps, afterBattle, game, owner, native, visitor, + location, steps, staging }; +} + +/** Capture the quote, accepted request, response/deployment choices and physical + * deposit. Keep invoice price separate from Tleilaxu's own free-return income. */ +export function requestNativeWorldRecruits(game: Game, actor: string, amount: number, elite = 0, + freeElite?: number) { + const before = structuredClone(game); + const player = nativeWorldRecruitsPlayer(before, actor); + const quote = forceRevivalQuote(before, player, amount, elite, freeElite); + const step: NativeWorldRecruitsStep = { actor, + action: { type: 'revive', amount, elite, ...(freeElite === undefined ? {} : { freeElite }) } }; + const nativeBefore = combatArmy(before, actor, `homeworld:${player.faction}`); + const requested = applyAction(game, actor, step.action); + const continuations: NativeWorldRecruitsStep[] = []; + game = advanceNativeWorldRecruits(requested, nativeWorldRecruitsClean, continuations); + const after = nativeWorldRecruitsPlayer(game, actor); + return { before, requested, game, step, continuations, quote, + rate: viewGame(before, actor).revival, + price: quote.cost, walletDelta: player.spice - after.spice, + free: (after.freeForcesRevived ?? 0) - (player.freeForcesRevived ?? 0), + nativeBefore, nativeAfter: combatArmy(game, actor, `homeworld:${player.faction}`) }; +} diff --git a/tests/native-world-battle.test.ts b/tests/native-world-battle.test.ts new file mode 100644 index 00000000..69e02a87 --- /dev/null +++ b/tests/native-world-battle.test.ts @@ -0,0 +1,160 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyAction, RuleError, viewGame } from '../game/engine'; +import { combatArmy } from '../game/combat-location'; +import { + advanceNativeWorldBattle, assertNativeWorldBattleInventory, createNativeWorldBattleFixture, + HARASS_WITHDRAW_CARD, nativeWorldBattleFinished, nativeWorldBattlePlayer, nativeWorldBattlePool, + REINFORCEMENTS_CARD, revealNativeWorldBattle, type NativeWorldBattleStep, +} from './fixture-native-world-battle'; + +for (const advanced of [false, true]) { + const rules = advanced ? 'Advanced' : 'Basic'; + + void test(`${rules}: nine paid Suboids retain original 1N4E reserves; Reinforcements pays 1N2E before Harass returns`, () => { + const f = createNativeWorldBattleFixture({ advanced }); + const shipment = f.shipments[0], original = nativeWorldBattlePlayer(shipment.before, f.owner); + const shipped = nativeWorldBattlePlayer(shipment.after, f.owner); + assert.equal(shipment.cost, 9); + assert.equal(shipped.spice, original.spice - 9); + assert.deepEqual(combatArmy(shipment.after, f.owner, f.location), { normal: 9, elite: 0 }); + assert.deepEqual(nativeWorldBattlePool(f.game, f.owner, 'homeworld:ixians'), { normal: 1, elite: 4 }); + assert.deepEqual(f.sources, { 'homeworld:ixians': { normal: 1, elite: 2 } }); + const before = structuredClone(f.game); + assert.throws(() => applyAction(f.game, f.owner, { ...f.planActions[0].action, + reinforcementsSources: { 'homeworld:ixians': { normal: 3, elite: 0 } } }), RuleError); + assert.deepEqual(f.game, before, 'Seven future returned Suboids cannot replace the two original Cyborg fees'); + assert.throws(() => applyAction(f.game, f.owner, { ...f.planActions[0].action, + reinforcementsSources: { [f.location]: { normal: 1, elite: 2 } } })); + assert.deepEqual(f.game, before, 'Foreign visitor stock is not a native fee source'); + + const revealed = revealNativeWorldBattle(f); + assert.deepEqual(viewGame(revealed, f.owner).battle!.traitorVoters, ['tleilaxu']); + assert.throws(() => applyAction(revealed, f.owner, { type: 'traitorCall', call: false })); + const steps: NativeWorldBattleStep[] = []; + const done = advanceNativeWorldBattle(revealed, nativeWorldBattleFinished, steps, { returns: f.returns }); + const p = nativeWorldBattlePlayer(done, f.owner); + assert.equal(done.lastBattleContext?.winner, 'tleilaxu'); + assert.equal(p.reserves, 9, 'Original five minus fee three plus seven returned normals'); + assert.equal(p.elites!.reserves, 2, 'Returned normals never undo the original two-Cyborg payment'); + assert.equal(p.tanks, 5, 'Fee three plus only the two committed Suboids'); + assert.equal(p.elites!.tanks, 2); + assert.deepEqual(nativeWorldBattlePool(done, f.owner, 'homeworld:ixians'), { normal: 7, elite: 2 }); + assert.deepEqual(combatArmy(done, f.owner, f.location), { normal: 0, elite: 0 }); + assert.ok(!done.pendingIxSubstitution); + for (const id of [REINFORCEMENTS_CARD, HARASS_WITHDRAW_CARD]) + assert.equal(done.discard.filter(c => c.id === id).length, 1); + assert.deepEqual(p.forces, nativeWorldBattlePlayer(f.afterSetup, f.owner).forces, 'Original HMS garrison stays physical'); + assertNativeWorldBattleInventory(done, f.afterSetup); + }); + + void test(`${rules}: own Ix removes local fees from the physical dial, retains Cyborg/Suboid traits and forbids Harass`, () => { + const f = createNativeWorldBattleFixture({ advanced, route: 'ix-native', faceDancer: true }); + assert.deepEqual(nativeWorldBattlePool(f.game, f.owner, f.location), { normal: 1, elite: 4 }); + const before = structuredClone(f.game); + assert.throws(() => applyAction(f.game, f.owner, { ...f.planActions[0].action, defense: HARASS_WITHDRAW_CARD }), + RuleError); + assert.throws(() => applyAction(f.game, f.owner, { ...f.planActions[0].action, dial: 4.5 }), + RuleError); + assert.deepEqual(f.game, before); + const revealed = revealNativeWorldBattle(f), view = viewGame(revealed, f.owner).battle!; + assert.ok(view.ownForces); + assert.equal(view.ownForces.normal, 0); + assert.equal(view.ownForces.elite, 2, 'Two local Cyborg fee counters cannot also be dialed'); + assert.equal(view.ownForces.normalFixedHalf, true); + assert.equal(view.ownForces.eliteStrength, 2); + assert.equal(view.ownForces.freeSupport, !advanced); + assert.deepEqual(view.traitorVoters, ['ixians']); + assert.throws(() => applyAction(revealed, 'guild', { type: 'traitorCall', call: false })); + const done = advanceNativeWorldBattle(revealed, nativeWorldBattleFinished); + const p = nativeWorldBattlePlayer(done, f.owner); + assert.equal(done.lastBattleContext?.winner, f.owner); + assert.equal(p.tanks, advanced ? 5 : 4); + assert.equal(p.elites!.tanks, advanced ? 4 : 3, 'Fee two plus only this dial’s actual Cyborg casualties'); + assert.deepEqual(combatArmy(done, f.owner, f.location), { normal: 0, elite: advanced ? 0 : 1 }); + assert.deepEqual(combatArmy(done, f.owner, 'homeworld:tleilaxu'), { normal: 9, elite: 0 }, + 'The separate real exported garrison is not a casualty or fee origin for this battle'); + assert.ok(nativeWorldBattlePlayer(done, 'tleilaxu').faceDancers!.some(d => + d.leader === f.planActions[0].action.leader && !d.revealed), + 'A matching spectator Face Dancer cannot act on another faction’s World'); + assertNativeWorldBattleInventory(done, f.afterSetup); + }); + + void test(`${rules}: paid typed invasion separates two fee Cyborgs from one battle Cyborg and resumes native Face Dance`, () => { + const f = createNativeWorldBattleFixture({ advanced, exportNormal: 9, exportElite: 1, + ownerDial: advanced ? 1 : 2, harass: false, outcome: 'win', faceDancer: true }); + assert.equal(f.shipments[0].cost, 10); + assert.equal(nativeWorldBattlePlayer(f.shipments[0].after, f.owner).spice, 0); + assert.deepEqual(combatArmy(f.game, f.owner, f.location), { normal: 9, elite: 1 }); + const steps: NativeWorldBattleStep[] = []; + let game = advanceNativeWorldBattle(revealNativeWorldBattle(f), g => g.decision?.kind === 'ixSubstitution', steps, + { casualties: { normal: 0, elite: 1 } }); + const ix = nativeWorldBattlePlayer(game, f.owner); + assert.equal(ix.tanks, 4); + assert.equal(ix.elites!.tanks, 3, 'Two reserve-fee Cyborgs and exactly one actual board casualty'); + assert.deepEqual(combatArmy(game, f.owner, f.location), { normal: 9, elite: 0 }); + const before = structuredClone(game); + assert.throws(() => applyAction(game, f.owner, { type: 'decision', sources: { [f.location]: 3 }, + recover: { [f.location]: 3 } }), RuleError); + assert.deepEqual(game, before, 'The fee cannot become two extra board-loss origins'); + game = applyAction(game, f.owner, { type: 'decision', sources: { [f.location]: 1 }, recover: { [f.location]: 1 } }); + game = advanceNativeWorldBattle(game, g => g.decision?.kind === 'faceDance', steps); + assert.deepEqual(combatArmy(game, f.owner, f.location), { normal: 8, elite: 1 }); + assert.equal(nativeWorldBattlePlayer(game, f.owner).elites!.tanks, 2, 'Only the battle Cyborg was recovered'); + assert.equal(nativeWorldBattlePlayer(game, f.owner).tanks, 4, 'One surviving Suboid replaces that Cyborg in Tanks'); + assert.equal(game.decision?.player, 'tleilaxu'); + assert.equal(game.decision?.kind === 'faceDance' && game.decision.territory, 'homeworld:tleilaxu'); + const offer = viewGame(game, 'tleilaxu').decision; + assert.ok(offer?.kind === 'faceDance'); + assert.equal(offer.blocked, undefined); + const dancer = nativeWorldBattlePlayer(game, 'tleilaxu').faceDancers!.find(d => d.leader === f.planActions[0].action.leader)!; + assert.ok(dancer && !dancer.revealed); + assertNativeWorldBattleInventory(game, f.afterSetup); + game = applyAction(game, 'tleilaxu', { type: 'decision', reveal: true, sources: {} }); + game = advanceNativeWorldBattle(game, nativeWorldBattleFinished, steps); + assert.deepEqual(combatArmy(game, f.owner, f.location), { normal: 0, elite: 0 }); + assert.deepEqual(nativeWorldBattlePool(game, f.owner, 'homeworld:ixians'), { normal: 8, elite: 2 }, + 'Eight Suboids and the one recovered Cyborg return to native Ix, joining its one remaining native Cyborg'); + const returned = nativeWorldBattlePlayer(game, f.owner); + assert.equal(returned.reserves, 10); + assert.equal(returned.elites!.reserves, 2); + assert.equal(returned.tanks, 4); + assert.equal(returned.elites!.tanks, 2); + assert.equal(returned.leaders.find(l => l.id === f.planActions[0].action.leader)!.dead, true); + assert.ok(nativeWorldBattlePlayer(game, 'tleilaxu').faceDancers!.find(d => d.leader === dancer.leader)!.revealed); + assertNativeWorldBattleInventory(game, f.afterSetup); + }); +} + +void test('Advanced: native CHOAM Reinforcements preserves the original Auditor response, payment and real inspection', () => { + const f = createNativeWorldBattleFixture({ advanced: true, route: 'choam-native' }); + let game = advanceNativeWorldBattle(revealNativeWorldBattle(f), g => g.decision?.kind === 'choamAudit'); + assert.equal(game.decision?.player, 'choam'); + assert.equal(game.lastBattleContext?.winner, 'choam'); + assert.deepEqual(combatArmy(game, 'choam', f.location), { normal: 17, elite: 0 }); + assert.equal(nativeWorldBattlePlayer(game, 'choam').tanks, 3); + const event = game.pendingAuditor!.event; + const held = structuredClone(nativeWorldBattlePlayer(game, 'guild').hand); + assert.equal(held.length, 1, 'One actual unused original card makes inspection deterministic without a replacement RNG'); + game = applyAction(game, 'choam', { type: 'decision', event, audit: true }); + game = advanceNativeWorldBattle(game, g => g.decision?.kind === 'choamAuditPayment'); + assert.equal(game.decision?.player, 'guild'); + const before = structuredClone(game); + const count = viewGame(game, 'choam').auditor!.count; + assert.equal(count, 1); + const paid = applyAction(game, 'guild', { type: 'decision', event, pay: true, count }); + assert.equal(nativeWorldBattlePlayer(paid, 'guild').spice, nativeWorldBattlePlayer(before, 'guild').spice - 1); + assert.equal(nativeWorldBattlePlayer(paid, 'choam').spice, nativeWorldBattlePlayer(before, 'choam').spice + 1); + assert.ok(!paid.auditorInsight, 'Payment cancels the inspection'); + assertNativeWorldBattleInventory(paid, f.afterSetup); + + game = applyAction(game, 'guild', { type: 'decision', event, pay: false }); + assert.deepEqual(game.auditorInsight?.cards.map(c => c.id), held.map(c => c.id)); + assert.equal(game.auditorInsight?.target, 'guild'); + assert.deepEqual(nativeWorldBattlePlayer(game, 'guild').hand.map(c => c.id), held.map(c => c.id), + 'Inspection transfers no physical card'); + assert.equal(nativeWorldBattlePlayer(game, 'guild').spice, nativeWorldBattlePlayer(before, 'guild').spice); + assert.equal(nativeWorldBattlePlayer(game, 'choam').spice, nativeWorldBattlePlayer(before, 'choam').spice); + game = advanceNativeWorldBattle(game, nativeWorldBattleFinished); + assertNativeWorldBattleInventory(game, f.afterSetup); +}); diff --git a/tests/native-world-marker.test.ts b/tests/native-world-marker.test.ts new file mode 100644 index 00000000..4d889e37 --- /dev/null +++ b/tests/native-world-marker.test.ts @@ -0,0 +1,218 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { applyAction, RuleError, viewGame, type Game } from '../game/engine'; +import { + createNativeWorldMarkerFixture, nativeWorldMarkerInventory, nativeWorldMarkerPlan, + type NativeWorldMarkerFixture, +} from './fixture-native-world-marker'; +import { + finishRicheseReinforcementsBattle, prepareRicheseReinforcementsPlan, + RICHESE_HARASS, RICHESE_REINFORCEMENTS, richeseReinforcementsPlayer, + sealRicheseReinforcementsPlan, +} from './fixture-richese-reinforcements'; + +function physicalCards(game: Game) { + return [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []), + ...game.players.flatMap(player => player.hand)].map(card => card.id).sort(); +} + +function primaryPool(game: Game, fixture: NativeWorldMarkerFixture) { + const home = viewGame(game, fixture.richese).homeworlds?.worlds + ?.find(world => world.id === fixture.source.homeworld); + assert.ok(home); + return home.forces[fixture.richese]; +} + +function assertConserved(fixture: NativeWorldMarkerFixture, game: Game) { + assert.deepEqual(physicalCards(game), physicalCards(fixture.initial), + 'Every original selected-deck/cache face retains one physical identity.'); + for (const player of game.players) { + const before = nativeWorldMarkerInventory(fixture.afterSetup, player.id); + const after = nativeWorldMarkerInventory(game, player.id); + assert.deepEqual([after.total, after.normal, after.elite], [before.total, before.normal, before.elite], + `${player.faction}'s native reserves count once; special counters remain their own physical type.`); + assert.equal(after.total, 20); + if (player.faction === 'ixians') assert.equal(after.elite, 7); + assert.ok(player.hand.length <= (player.faction === 'harkonnen' ? 8 : 4)); + } + const original = richeseReinforcementsPlayer(fixture.afterSetup, fixture.richese).noField!; + const current = richeseReinforcementsPlayer(game, fixture.richese).noField!; + assert.deepEqual(current.tokens, original.tokens, 'No token is minted, replaced or relabeled.'); + assert.equal(current.lastShipped, fixture.token.id, 'Reveal preserves the last real shipment identity.'); +} + +function assertSpent(game: Game, card: string) { + assert.equal(game.discard.filter(face => face.id === card).length, 1); + assert.ok(game.players.every(player => player.hand.every(face => face.id !== card))); + assert.ok(game.deck.every(face => face.id !== card)); +} + +/** Preview and the first seal must not perform the eventual native reveal. */ +function assertStillConcealed(fixture: NativeWorldMarkerFixture, game: Game) { + const owner = richeseReinforcementsPlayer(game, fixture.richese); + const before = richeseReinforcementsPlayer(fixture.game, fixture.richese); + assert.equal(game.battle!.revealed, false); + assert.deepEqual(owner.noField, before.noField); + assert.equal(owner.noFieldEvent, before.noFieldEvent); + assert.equal(owner.reserves, fixture.source.reservesBeforeReveal); + assert.deepEqual(primaryPool(game, fixture), { normal: owner.reserves, elite: 0 }); + assert.equal(owner.forces[fixture.location] ?? 0, 0, 'A concealed marker is not a physical counter.'); + assert.equal(owner.tanks, before.tanks); +} + +for (const family of ['choam', 'ix+choam'] as const) { + // Opposite representative slots cover both paths without a Cartesian matrix. + const slots = [family === 'choam' ? 'weapon' : 'defense'] as const; + for (const slot of slots) + void test(`${family} World marker three funds R in the ${slot} slot only after its real primary reserve debit`, () => { + const fixture = createNativeWorldMarkerFixture({ family, marker: 3, reserves: 6 }); + const original = structuredClone(fixture.game); + const preview = viewGame(fixture.game, fixture.richese).battle!.reinforcements!; + assert.deepEqual(fixture.game, original, 'The owner forecast does not materialize forces or update history.'); + assert.equal(preview.blocked, null); + assert.deepEqual(preview.noField, { materializes: 3, reservesAfterReveal: 3 }); + assertStillConcealed(fixture, fixture.game); + + const first = prepareRicheseReinforcementsPlan(fixture.game, nativeWorldMarkerPlan(fixture, 'reinforcements', slot)); + assertStillConcealed(fixture, first); + const revealed = sealRicheseReinforcementsPlan(first, fixture.plans.opponent); + const owner = richeseReinforcementsPlayer(revealed, fixture.richese); + assert.equal(revealed.battle!.revealed, true); + assert.deepEqual(primaryPool(revealed, fixture), { normal: 3, elite: 0 }); + assert.equal(owner.reserves, 3, 'Materialization spends the same native primary counters once.'); + assert.equal(owner.forces[fixture.location], 3); + assert.equal(owner.tanks, 0, 'The provisional fee settles at the actual result, not at first seal.'); + assert.equal(owner.noField!.deployed, null); + assertConserved(fixture, revealed); + + const done = finishRicheseReinforcementsBattle(revealed); + const after = richeseReinforcementsPlayer(done, fixture.richese); + assert.equal(done.lastBattleContext!.result, 'normal'); + assert.equal(done.lastBattleContext!.winner, fixture.opponent); + assert.deepEqual(primaryPool(done, fixture), { normal: 0, elite: 0 }); + assert.equal(after.reserves, 0); + assert.equal(after.tanks, 6, 'Three materialized casualties plus the three-reserve fee, not nine counters.'); + assert.equal(after.battleLosses, 6); + assert.equal(after.forces[fixture.location] ?? 0, 0); + assertSpent(done, RICHESE_REINFORCEMENTS); + assertConserved(fixture, done); + }); + + for (const [marker, reserves, materializes] of [[0, 6, 0], [5, 8, 5]] as const) + void test(`${family} World funded marker ${marker} keeps its physical token and pays from the remaining native reserves`, () => { + const fixture = createNativeWorldMarkerFixture({ family, marker, reserves }); + const preview = viewGame(fixture.game, fixture.richese).battle!.reinforcements!; + assert.equal(preview.blocked, null); + assert.deepEqual(preview.noField, { materializes, reservesAfterReveal: reserves - materializes }); + const first = prepareRicheseReinforcementsPlan(fixture.game, nativeWorldMarkerPlan(fixture)); + assertStillConcealed(fixture, first); + const revealed = sealRicheseReinforcementsPlan(first, fixture.plans.opponent); + assert.deepEqual(primaryPool(revealed, fixture), { normal: reserves - materializes, elite: 0 }); + assert.equal(richeseReinforcementsPlayer(revealed, fixture.richese).forces[fixture.location] ?? 0, materializes); + const done = finishRicheseReinforcementsBattle(revealed); + const after = richeseReinforcementsPlayer(done, fixture.richese); + assert.equal(after.reserves, reserves - materializes - 3); + assert.deepEqual(primaryPool(done, fixture), { normal: after.reserves, elite: 0 }); + assert.equal(after.tanks, materializes + 3); + assertSpent(done, RICHESE_REINFORCEMENTS); + assertConserved(fixture, done); + }); + + for (const [marker, reserves, materializes] of [[3, 5, 3], [5, 3, 3]] as const) + for (const slot of slots) + void test(`${family} World marker ${marker}/${reserves} refuses short R in ${slot} unchanged, without borrowing a future H return`, () => { + const fixture = createNativeWorldMarkerFixture({ family, marker, reserves }); + const before = structuredClone(fixture.game); + const preview = viewGame(fixture.game, fixture.richese).battle!.reinforcements!; + assert.ok(preview.blocked); + assert.deepEqual(preview.noField, { materializes, reservesAfterReveal: reserves - materializes }); + assert.equal(preview.normal + preview.elite, reserves - materializes); + assert.equal(viewGame(fixture.game, fixture.richese).battle!.harassWithdraw!.blocked, null); + assert.deepEqual(fixture.game, before); + // This exact original marker-three/reserves-five native position used + // to accept the raw first seal even though its owner preview refused it. + const rawR = nativeWorldMarkerPlan(fixture, 'reinforcements', slot); + delete rawR.action.reinforcementsSources; + for (const plan of [rawR, nativeWorldMarkerPlan(fixture, 'both', slot)]) { + assert.throws(() => applyAction(fixture.game, plan.actor, plan.action), RuleError); + assert.deepEqual(fixture.game, before, 'Rejection changes no wallet, cards, token, history, counters or windows.'); + assert.deepEqual(fixture.game.battle!.plans, {}); + } + assertStillConcealed(fixture, fixture.game); + const first = prepareRicheseReinforcementsPlan(fixture.game, nativeWorldMarkerPlan(fixture, 'harass', slot)); + assertStillConcealed(fixture, first); + const revealed = sealRicheseReinforcementsPlan(first, fixture.plans.opponent); + assert.deepEqual(primaryPool(revealed, fixture), { normal: reserves - materializes, elite: 0 }); + assert.equal(richeseReinforcementsPlayer(revealed, fixture.richese).forces[fixture.location], materializes, + 'A five marker capped at three reserves reveals three, not five.'); + const done = finishRicheseReinforcementsBattle(revealed); + const after = richeseReinforcementsPlayer(done, fixture.richese); + assert.equal(after.reserves, reserves - materializes + 1); + assert.deepEqual(primaryPool(done, fixture), { normal: after.reserves, elite: 0 }, + 'The real undialed counter deposits in the native primary World, not a foreign visitor pool.'); + assert.equal(after.tanks, 2, 'Only the dialed counters die under H-only fallback.'); + assertSpent(done, RICHESE_HARASS); + assert.ok(after.hand.some(card => card.id === RICHESE_REINFORCEMENTS), 'The rejected R remains unspent.'); + assertConserved(fixture, done); + }); + + for (const slot of slots) + void test(`${family} World R+H in ${slot} charges native reserves before depositing the real undialed survivor`, () => { + const fixture = createNativeWorldMarkerFixture({ family, marker: 3, reserves: 6 }); + const first = prepareRicheseReinforcementsPlan(fixture.game, nativeWorldMarkerPlan(fixture, 'both', slot)); + assertStillConcealed(fixture, first); + const revealed = sealRicheseReinforcementsPlan(first, fixture.plans.opponent); + assert.deepEqual(primaryPool(revealed, fixture), { normal: 3, elite: 0 }); + const done = finishRicheseReinforcementsBattle(revealed); + const after = richeseReinforcementsPlayer(done, fixture.richese); + assert.deepEqual(primaryPool(done, fixture), { normal: 1, elite: 0 }); + assert.equal(after.reserves, 1, 'Three pay first; the one real undialed survivor returns afterwards.'); + assert.equal(after.tanks, 5); + assert.equal(after.battleLosses, 5); + assertSpent(done, RICHESE_REINFORCEMENTS); + assertSpent(done, RICHESE_HARASS); + assertConserved(fixture, done); + }); + + for (const marker of [0, 3, 5] as const) + for (const slot of slots) + void test(`${family} Advanced World marker ${marker} pays R from its funded turn-one primary World in ${slot}`, () => { + const fixture = createNativeWorldMarkerFixture({ family, advanced: true, marker }); + const before = richeseReinforcementsPlayer(fixture.game, fixture.richese); + const preview = viewGame(fixture.game, fixture.richese).battle!.reinforcements!; + assert.equal(preview.blocked, null); + assert.deepEqual(preview.noField, { materializes: marker, reservesAfterReveal: 20 - marker }); + const first = prepareRicheseReinforcementsPlan(fixture.game, nativeWorldMarkerPlan(fixture, 'reinforcements', slot)); + assertStillConcealed(fixture, first); + const revealed = sealRicheseReinforcementsPlan(first, fixture.plans.opponent); + assert.equal(richeseReinforcementsPlayer(revealed, fixture.richese).forces[fixture.location] ?? 0, marker); + assert.deepEqual(primaryPool(revealed, fixture), { normal: 20 - marker, elite: 0 }); + const done = finishRicheseReinforcementsBattle(revealed); + const after = richeseReinforcementsPlayer(done, fixture.richese); + assert.equal(after.reserves, 17 - marker); + assert.deepEqual(primaryPool(done, fixture), { normal: 17 - marker, elite: 0 }); + assert.equal(after.tanks, marker + 3); + assert.equal(after.spice, before.spice - Number(fixture.plans.reinforcements.action.support), + 'The original physical Advanced support is still paid, not increased by two score strength.'); + assertSpent(done, RICHESE_REINFORCEMENTS); + assertConserved(fixture, done); + }); + + void test(`${family} Advanced World marker five settles R then deposits four H survivors with original support`, () => { + const fixture = createNativeWorldMarkerFixture({ family, advanced: true, marker: 5 }); + const before = richeseReinforcementsPlayer(fixture.game, fixture.richese); + const first = prepareRicheseReinforcementsPlan(fixture.game, nativeWorldMarkerPlan(fixture, 'both')); + const revealed = sealRicheseReinforcementsPlan(first, fixture.plans.opponent); + assert.deepEqual(primaryPool(revealed, fixture), { normal: 15, elite: 0 }); + const done = finishRicheseReinforcementsBattle(revealed); + const after = richeseReinforcementsPlayer(done, fixture.richese); + assert.equal(after.reserves, 16, 'Fifteen minus the fee plus four actual undialed survivors.'); + assert.deepEqual(primaryPool(done, fixture), { normal: 16, elite: 0 }); + assert.equal(after.tanks, 4); + assert.equal(after.spice, before.spice - 1); + assertSpent(done, RICHESE_REINFORCEMENTS); + assertSpent(done, RICHESE_HARASS); + assertConserved(fixture, done); + }); + +} diff --git a/tests/native-world-recruits.test.ts b/tests/native-world-recruits.test.ts new file mode 100644 index 00000000..9d21e3f0 --- /dev/null +++ b/tests/native-world-recruits.test.ts @@ -0,0 +1,229 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { applyAction, RuleError, viewGame, type Action, type Game } from '../game/engine'; +import { combatArmy } from '../game/combat-location'; +import { RECRUITS_CARD_ID } from '../game/recruits'; +import { + advanceNativeWorldRecruits, assertNativeWorldRecruitsInventory, createNativeWorldRecruitsFixture, + nativeWorldRecruitsClean, nativeWorldRecruitsPlayer, requestNativeWorldRecruits, +} from './fixture-native-world-recruits'; + +const play = { type: 'card', card: RECRUITS_CARD_ID } as const; +function rejectsUnchanged(game: Game, actor: string, action: Action): void { + const before = JSON.stringify(game); + assert.throws(() => applyAction(game, actor, action), RuleError); + assert.equal(JSON.stringify(game), before); +} + +function continueToShipment(game: Game): Game { + const next = advanceNativeWorldRecruits(game, g => g.turn === 2 && g.phase === 5 && nativeWorldRecruitsClean(g)); + assert.ok(next.active, 'Original next Shipment must have its actual active seat'); + assertNativeWorldRecruitsInventory(next); + return next; +} + +for (const advanced of [false, true]) { + const mode = advanced ? 'Advanced' : 'Basic'; + + void test(`${mode} native Ix deposits paid cyborgs and free suboids, reopens with Recruits, then reprices after population five`, () => { + const fixture = createNativeWorldRecruitsFixture({ advanced, faction: 'ixians' }); + const native = fixture.native; + assert.equal(nativeWorldRecruitsPlayer(fixture.game, native).spice, 10); + assert.equal(nativeWorldRecruitsPlayer(fixture.game, native).tanks, 14); + assert.equal(nativeWorldRecruitsPlayer(fixture.game, native).elites!.tanks, 4); + assert.deepEqual(combatArmy(fixture.game, fixture.visitor, fixture.location), { normal: 1, elite: 0 }); + assert.equal(nativeWorldRecruitsPlayer(fixture.afterShipment, fixture.visitor).spice, + nativeWorldRecruitsPlayer(fixture.beforeShipment, fixture.visitor).spice - 1); + const paid = requestNativeWorldRecruits(fixture.game, native, 3, 1, 0); + assert.equal(paid.price, 3, 'Two free suboids leave one cyborg at its original three-spice price'); + assert.equal(paid.walletDelta, 3); + assert.equal(paid.free, 2); + assert.deepEqual(paid.nativeAfter, { normal: 2, elite: 1 }); + let game = applyAction(paid.game, native, { type: 'ready' }); + game = applyAction(game, fixture.owner, play); + assert.equal(nativeWorldRecruitsPlayer(game, native).spice, 7, 'Activation never refunds the paid cyborg'); + assert.equal(viewGame(game, native).revival.freeRemaining, 2); + const crossing = requestNativeWorldRecruits(game, native, 2, 2, 2); + assert.equal(crossing.price, 0); + assert.equal(crossing.free, 2); + assert.deepEqual(crossing.nativeAfter, { normal: 2, elite: 3 }); + game = crossing.game; + const rate = viewGame(game, native).revival; + assert.equal(rate.homeworldBonus, 0); + assert.equal(rate.freeRemaining, 0, 'A separate request uses the now-high native World, not the original four-free rate'); + assert.equal(rate.eliteRemaining, 1, 'Ix retains its native multiple-cyborg allowance in both modes'); + rejectsUnchanged(game, native, { type: 'revive', amount: 2, elite: 2 }); + const last = requestNativeWorldRecruits(game, native, 2, 1, 0); + assert.equal(last.price, 5, 'One paid cyborg plus one paid suboid still costs five'); + assert.equal(last.walletDelta, 5); + assert.deepEqual(last.nativeAfter, { normal: 3, elite: 4 }); + assert.equal(nativeWorldRecruitsPlayer(last.game, native).freeForcesRevived, 4); + assert.equal(nativeWorldRecruitsPlayer(last.game, native).revived, 7); + assert.equal(nativeWorldRecruitsPlayer(last.game, native).spice, 2); + assert.equal(nativeWorldRecruitsPlayer(last.game, native).tanks, 7); + assert.equal(nativeWorldRecruitsPlayer(last.game, native).elites!.tanks, 0); + rejectsUnchanged(last.game, native, { type: 'revive', amount: 1, elite: 0 }); + assert.equal(last.game.discard.filter(c => c.id === RECRUITS_CARD_ID).length, 1); + continueToShipment(last.game); + }); + + void test(`${mode} the real mixed Ix Tanks retain the player's free-cyborg allocation and its distinct prices`, () => { + const fixture = createNativeWorldRecruitsFixture({ advanced, faction: 'ixians' }); + const active = applyAction(fixture.game, fixture.owner, play); + rejectsUnchanged(active, fixture.native, { type: 'revive', amount: 8, elite: 3, freeElite: 0 }); + rejectsUnchanged(active, fixture.native, { type: 'revive', amount: 7, elite: 3, freeElite: 4 }); + for (const freeElite of [0, 3]) { + const returned = requestNativeWorldRecruits(structuredClone(active), fixture.native, 7, 3, freeElite); + assert.equal(returned.free, 4); + assert.equal(returned.price, freeElite === 0 ? 9 : 6); + assert.equal(returned.walletDelta, freeElite === 0 ? 9 : 6); + assert.deepEqual(returned.nativeAfter, { normal: 4, elite: 3 }); + assert.equal(nativeWorldRecruitsPlayer(returned.game, fixture.native).elites!.tanks, 1); + assert.equal(nativeWorldRecruitsPlayer(returned.game, fixture.native).tanks, 7); + assertNativeWorldRecruitsInventory(returned.game); + continueToShipment(returned.game); + } + }); + + void test(`${mode} native Tleilaxu accepts eleven in one current-rate request without the ordinary seven cap`, () => { + const fixture = createNativeWorldRecruitsFixture({ advanced, faction: 'tleilaxu' }); + let game = applyAction(fixture.game, fixture.owner, play); + assert.equal(nativeWorldRecruitsPlayer(game, fixture.native).spice, 5); + rejectsUnchanged(game, fixture.native, { type: 'revive', amount: 12, elite: 0 }); + const returned = requestNativeWorldRecruits(game, fixture.native, 11); + assert.equal(returned.price, 5, 'Six free and five half-price paid forces retain their accepted invoice'); + assert.equal(returned.free, 6); + assert.equal(returned.walletDelta, 4, 'Original own free-revival income pays one spice separately'); + game = returned.game; + assert.equal(nativeWorldRecruitsPlayer(game, fixture.native).spice, 1); + assert.equal(nativeWorldRecruitsPlayer(game, fixture.native).revived, 11); + assert.equal(nativeWorldRecruitsPlayer(game, fixture.native).freeForcesRevived, 6); + assert.equal(nativeWorldRecruitsPlayer(game, fixture.native).tanks, 9); + assert.deepEqual(returned.nativeAfter, { normal: 11, elite: 0 }); + assert.equal(viewGame(game, fixture.native).revival.limit, 20); + assert.equal(viewGame(game, fixture.native).revival.forcesRemaining, 9); + assert.equal(viewGame(game, fixture.native).revival.freeRemaining, 0); + assert.equal(viewGame(game, fixture.native).revival.homeworldBonus, 0); + continueToShipment(game); + }); + + void test(`${mode} paid Tleilaxu returns are not refunded and a separate crossing request loses the low-World free bonus`, () => { + const fixture = createNativeWorldRecruitsFixture({ advanced, faction: 'tleilaxu' }); + const first = requestNativeWorldRecruits(fixture.game, fixture.native, 8); + assert.equal(first.price, 5); + assert.equal(first.free, 3); + assert.equal(first.walletDelta, 4); + let game = applyAction(first.game, fixture.owner, play); + assert.equal(nativeWorldRecruitsPlayer(game, fixture.native).spice, 1); + assert.equal(viewGame(game, fixture.native).revival.freeRemaining, 3); + const crossing = requestNativeWorldRecruits(game, fixture.native, 1); + assert.equal(crossing.free, 1); + assert.equal(crossing.price, 0); + assert.equal(crossing.walletDelta, 0, 'Own free-revival income is not awarded a second time'); + game = crossing.game; + assert.deepEqual(crossing.nativeAfter, { normal: 9, elite: 0 }); + assert.equal(viewGame(game, fixture.native).revival.freeRemaining, 0); + assert.equal(viewGame(game, fixture.native).revival.homeworldBonus, 0); + assert.equal(nativeWorldRecruitsPlayer(game, fixture.native).freeForcesRevived, 4); + rejectsUnchanged(game, fixture.native, { type: 'revive', amount: 2, elite: 0 }); + const paid = requestNativeWorldRecruits(game, fixture.native, 1); + assert.equal(paid.price, 1); + assert.equal(paid.walletDelta, 1); + assert.equal(paid.free, 0); + assert.deepEqual(paid.nativeAfter, { normal: 10, elite: 0 }); + assert.equal(nativeWorldRecruitsPlayer(paid.game, fixture.native).freeForcesRevived, 4); + assert.equal(nativeWorldRecruitsPlayer(paid.game, fixture.native).spice, 0); + continueToShipment(paid.game); + }); + + void test(`${mode} native CHOAM keeps unlimited one-spice returns and prior paid/free ledgers after Recruits`, () => { + const fixture = createNativeWorldRecruitsFixture({ advanced, faction: 'choam' }); + assert.equal(nativeWorldRecruitsPlayer(fixture.game, fixture.native).spice, 15, 'Real CHOAM phase income funds the programme'); + const first = requestNativeWorldRecruits(fixture.game, fixture.native, 8); + assert.equal(first.price, 7); + assert.equal(first.walletDelta, 7); + assert.equal(first.free, 1); + let game = applyAction(first.game, fixture.owner, play); + assert.equal(nativeWorldRecruitsPlayer(game, fixture.native).spice, 8); + assert.equal(viewGame(game, fixture.native).revival.limit, 20); + assert.equal(viewGame(game, fixture.native).revival.freeRemaining, 1); + const free = requestNativeWorldRecruits(game, fixture.native, 1); + assert.equal(free.price, 0); + assert.equal(free.free, 1); + const paid = requestNativeWorldRecruits(free.game, fixture.native, 3); + assert.equal(paid.price, 3); + assert.equal(paid.walletDelta, 3); + assert.equal(paid.free, 0); + game = paid.game; + assert.deepEqual(paid.nativeAfter, { normal: 12, elite: 0 }); + assert.equal(nativeWorldRecruitsPlayer(game, fixture.native).spice, 5); + assert.equal(nativeWorldRecruitsPlayer(game, fixture.native).freeForcesRevived, 2); + assert.equal(nativeWorldRecruitsPlayer(game, fixture.native).revived, 12); + assert.equal(viewGame(game, fixture.native).revival.forcesRemaining, 8); + assert.equal(viewGame(game, fixture.native).revival.homeworldBonus, 0); + rejectsUnchanged(game, fixture.native, { type: 'revive', amount: 6, elite: 0 }); + continueToShipment(game); + }); + + void test(`${mode} native CHOAM can actually return seventeen with Recruits rather than merely quoting unlimited`, () => { + const fixture = createNativeWorldRecruitsFixture({ advanced, faction: 'choam' }); + const active = applyAction(fixture.game, fixture.owner, play); + rejectsUnchanged(active, fixture.native, { type: 'revive', amount: 18, elite: 0 }); + const returned = requestNativeWorldRecruits(active, fixture.native, 17); + assert.equal(returned.free, 2); + assert.equal(returned.price, 15); + assert.equal(returned.walletDelta, 15); + assert.deepEqual(returned.nativeAfter, { normal: 17, elite: 0 }); + assert.equal(nativeWorldRecruitsPlayer(returned.game, fixture.native).spice, 0); + assert.equal(nativeWorldRecruitsPlayer(returned.game, fixture.native).tanks, 3); + assert.equal(viewGame(returned.game, fixture.native).revival.forcesRemaining, 3); + continueToShipment(returned.game); + }); + + void test(`${mode} ordinary native Richese reopens a ready revival, receives six free plus one paid, and still stops at seven`, () => { + const fixture = createNativeWorldRecruitsFixture({ advanced, faction: 'richese' }); + const spice = nativeWorldRecruitsPlayer(fixture.game, fixture.native).spice; + const first = requestNativeWorldRecruits(fixture.game, fixture.native, 3); + assert.equal(first.price, 0); + let game = applyAction(first.game, fixture.native, { type: 'ready' }); + game = applyAction(game, fixture.owner, play); + assert.equal(viewGame(game, fixture.native).revival.freeRemaining, 3); + rejectsUnchanged(game, fixture.native, { type: 'revive', amount: 5, elite: 0 }); + const free = requestNativeWorldRecruits(game, fixture.native, 3); + assert.equal(free.price, 0); + assert.equal(free.free, 3); + const paid = requestNativeWorldRecruits(free.game, fixture.native, 1); + assert.equal(paid.price, 2); + assert.equal(paid.walletDelta, 2); + assert.equal(paid.free, 0); + assert.deepEqual(paid.nativeAfter, { normal: 7, elite: 0 }); + assert.equal(nativeWorldRecruitsPlayer(paid.game, fixture.native).freeForcesRevived, 6); + assert.equal(nativeWorldRecruitsPlayer(paid.game, fixture.native).tanks, 13); + assert.equal(nativeWorldRecruitsPlayer(paid.game, fixture.native).spice, spice - 2); + rejectsUnchanged(paid.game, fixture.native, { type: 'revive', amount: 1, elite: 0 }); + continueToShipment(paid.game); + }); + + void test(`${mode} actual World battle Sardaukar casualties retain the ordinary elite cap despite Recruits`, () => { + const fixture = createNativeWorldRecruitsFixture({ advanced, faction: 'ixians', imperialCasualties: true }); + assert.equal(fixture.shipmentPrice, 2); + assert.equal(nativeWorldRecruitsPlayer(fixture.game, fixture.visitor).tanks, 2); + assert.equal(nativeWorldRecruitsPlayer(fixture.game, fixture.visitor).elites!.tanks, 2); + let game = applyAction(fixture.game, fixture.owner, play); + if (advanced) rejectsUnchanged(game, fixture.visitor, { type: 'revive', amount: 2, elite: 2 }); + const returned = requestNativeWorldRecruits(game, fixture.visitor, advanced ? 1 : 2, advanced ? 1 : 2); + assert.equal(returned.price, 0); + assert.equal(returned.walletDelta, 0); + game = returned.game; + const after = nativeWorldRecruitsPlayer(game, fixture.visitor); + assert.equal(after.elites!.tanks, advanced ? 1 : 0); + assert.equal(after.elites!.reserves, advanced ? 4 : 5); + const worlds = viewGame(game, fixture.visitor).homeworlds?.worlds; + assert.ok(worlds, 'The visitor needs its native World custody view'); + const destination = worlds.find(w => w.native === fixture.visitor && w.secondary === advanced); + assert.ok(destination); + assert.equal((destination.forces[fixture.visitor] ?? { normal: 0, elite: 0 }).elite, advanced ? 4 : 5); + if (advanced) rejectsUnchanged(game, fixture.visitor, { type: 'revive', amount: 1, elite: 1 }); + continueToShipment(game); + }); +} diff --git a/tools/start-prototype.ts b/tools/start-prototype.ts index ccce3de9..76c355fd 100644 --- a/tools/start-prototype.ts +++ b/tools/start-prototype.ts @@ -37,6 +37,7 @@ async function main() { '\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, 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.', + '\nThe same --profile ecaz-treachery --homeworlds entry admits clean selected Ix and/or CHOAM native/base Basic/Advanced2–6 rosters without other modules, using original38/50 plus separate Richese cache when seated. Native HMS/Cyborgs, Face Dancers, Auditor and concealed markers remain original. Native rates/unlimited caps and exact fee/return custody stay; own-World Harass is forbidden. Native Richese must leave three actual World reserves after No-Field materialization before R seals. No Tech/Strongholds/Skills/Nexus/Discovery/Sandtrout, E3, other previews, public starts or played-game conversion opens for these native World profiles.', '\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.' +