Compose World Skills cards with selected ordinary decks

This commit is contained in:
Quintine committed 2026-10-17 23:07:39 +11:00
1 parent b9330d489e
commit 01985af36b
36 files changed
+1201 -283

No files matched your search

+7 -6
View File
@@ -3,7 +3,7 @@
Development integration, 21 September 2026. Genuine CHOAM/Skills setup already
existed. This follow-up connects the ordinary skill action paths for Basic CHOAM
with base opponents and checks native card sales and revival interruptions.
It does not certify the full expansion or open public starts/publication.
Normal starts are labelled previews, not full-expansion/publication acceptance.
**4 October Advanced continuation:** native CHOAM and/or Ixians with classic
opponents now compose under the
@@ -14,11 +14,12 @@ Other Richese/module compositions below retain their separate guards.
## Configuration and components
The shared ordinary-skill profile requires Basic rules, exactly CHOAM & Richese,
two through six players including CHOAM, and no other optional modules. Other
seats belong to the six base factions. The existing initializer's wider Richese
and Advanced development admission is retained; those action combinations keep
their separate implementation boundaries.
Fresh Basic/Advanced2–6 uses the shared selected-deck roster contract: select
CHOAM for a CHOAM/Richese seat, with other selected E1/E2/classic seats allowed.
Neither a seated CHOAM nor independent cards is required just to select that
ordinary deck. Existing World/all14/card/Tech/Advanced Stronghold compositions
follow the [17 October current scope](LEADER_SKILLS_RUNTIME.md#world-skills-and-selected-decks--17-october-2026),
not the original single-family/no-module software restriction.
Setup keeps all fourteen physical skill cards and the real 35-card Treachery
deck: the base cards plus Poison Tooth and Artillery. Neither added battle card
+26
View File
@@ -29,6 +29,32 @@ optional skill bands and combinations remain open.
Rules implementation continues; comprehensive assurance and administration
remain deferred until all rules are implemented.
**World Skills and selected decks — 17 October.** Eight original World/card/
Skills phase starts and five bare selected-deck starts rejected before; one
selected-deck roster capability now serves Basic/Advanced without a required
native seat or independent cards. Explicit Advanced foreign-ghola authorization
remains. Unused Ecaz metadata is not a native E3 seat. Original classic/E1/E2
Worlds now retain all14/cards and original Tech/Advanced Strongholds; actual
E3 World cards, Nexus/Discovery and unrelated role/effect guards remain separate.
Genuine World revival keeps accepted invoices, native unlimited allowances and
separate Axlotl/normal Banker stores. Own-World fees cannot also be dialed or
rescued; visitor H/Suk keeps original typed destinations and every own-World
Harass ban remains. Marker funding is still checked after projected
materialization, before withdrawal. Earned support and skills supply no fee
subsidy. No counter, owner, wallet, phase or played-save history is fabricated.
Frozen61-file **683/683**, types/lint/build and actual rule programmes pass.
The harness now consumes optional Mentat naming and includes the original
pre-training three-card Ix draft in its stock count; null-preview/profile-copy
pins are removed. Two normal World tables exercise all four legal policies
through turn3 or an earlier win. Actual390px five-module normal entry completes
training/Traitor choice into Storm with Kaitain/Salusa/Stronghold inspectors,
no browser errors or root overflow. Detailed targeted programmes, not these
natural tables, prove fees, income and rescue. No complete-mode, calibration,
deployed or deferred assurance claim follows.
[Current envelope and source-bound evidence](LEADER_SKILLS_RUNTIME.md#world-skills-and-selected-decks--17-october-2026).
**Skills/cards with original phase modules — 17 October.** Fresh all14 Skills
and independent cards now retains original Tech3+ Basic/Advanced and
Strongholds2+ Advanced, optionally both, across existing classic, selected
+21
View File
@@ -436,3 +436,24 @@ Controlled physical card staging is labelled; the Richese second R is not a
claimed natural reacquisition. No complete-mode or deployed acceptance follows.
[Canonical composition/evidence](LEADER_SKILLS_RUNTIME.md#independent-cards-with-original-phase-modules--17-october-2026).
## World Skills and selected decks — 17 October 2026
The existing printed variant independence now composes original classic or
selected E1/E2 World/card starts with all14 Skills, including original Tech3+
and Advanced Strongholds2+. Selected unused Ecaz metadata is not an actual
Ecaz/Moritani seat; actual E3 World/card rosters, Nexus/Discovery and unrelated
effect guards remain separate. Bare ordinary all14 selected-deck starts also
need neither a native seat nor these three cards.
Original current-population revival invoices, native unlimited allowances and
separate Axlotl/Banker income remain. R seals actual normal-first native sources;
own-World fee and dial cannot overlap, Suk saves only committed losses and H
keeps its printed own-World ban and typed visitor destinations. Richese still
materializes before its three-reserve fee, then returns only real undialed H
survivors. Earned support and skill strength cannot fund that fee.
The frozen61-file683/683, compiler/lint/build and genuine rule/control proofs
are recorded in the [canonical current composition](LEADER_SKILLS_RUNTIME.md#world-skills-and-selected-decks--17-october-2026).
Controlled lotteries/physical card positioning are labelled; no printed-number
ruling, save retrofit, full-mode or deployed certification follows.
+12 -5
View File
@@ -1,10 +1,17 @@
# Homeworld setup and native reserve runtime
9 September 2026. This checkpoint connects the physical Homeworld model to
the authoritative game, player table and AI. **Public Homeworld starts remain
disabled.** It does not certify Homeworld battles, occupation, card effects or
complete Advanced/expansion games. The earlier component checkpoint remains
in [HOMEWORLD_IMPLEMENTATION.md](HOMEWORLD_IMPLEMENTATION.md).
The original 9 September checkpoint connects the physical Homeworld model to
the authoritative game, player table and AI. Normal Homeworld starts are now
labelled previews, not complete Advanced/expansion acceptance. The earlier
component checkpoint remains in [HOMEWORLD_IMPLEMENTATION.md](HOMEWORLD_IMPLEMENTATION.md).
**17 October World Skills/cards:** original classic/selected E1/E2 World starts
now compose all14 and independent cards, optionally original Tech/Advanced
Strongholds. Actual E3 World cards and Nexus/Discovery remain separate. Original
typed sources, own-World fee/dial disjointness, committed-only Suk, current-rate
revival and every own-World Harass prohibition remain. No World/counter/wallet
or played history is manufactured.
[Current scope and683/683 proof](LEADER_SKILLS_RUNTIME.md#world-skills-and-selected-decks--17-october-2026).
## Mixed E1/E2 and paired E3 Nexus — 7 October 2026
+28
View File
@@ -1,5 +1,33 @@
# Dune implementation status
## 17 October 2026 — World Skills/cards and selected ordinary decks
Eight actual World/card/Skills phase starts and five bare selected-deck starts
rejected before. One selected-deck roster capability now serves Basic/Advanced
without incidental native-seat/card dependencies; explicit Advanced foreign-ghola
authorization stays. Actual poisoned-disc deaths precede the Basic rejection
and Advanced paid revival. Unused Ecaz metadata creates no native E3 power.
Classic/E1/E2 World cards now compose all14 and original Tech/Advanced
Strongholds. Genuine native programmes retain accepted revival invoices,
separate Axlotl/Banker stores, own-World fee/dial disjointness, committed-only
Suk, typed visitor returns and every own-World H ban. Original marker
materialization must still leave three fee counters before later H returns;
skill strength/earned support is no fee subsidy. Actual E3 World cards,
Nexus/Discovery, unrelated guards and existing provisional timing stay separate.
Frozen61-file **683/683**, types/lint/build and actual rule programmes pass.
Three initial failures were fixture expectations/continuations: consume only
optional Mentat naming, delete null-preview pins and count the three real Ix
draft cards before training. Actual capped5/stock3 rejects R; legal H returns
one/loses two. Actual own-Ix Suk saves committed2E, never fee1N2E; funded Richese
marker5 pays3 then returns4. Normal World policies reach turn3/earlier win;
390px normal five-module entry reaches Storm with native Emperor World and
Stronghold inspectors, no browser errors/overflow. No natural card-reacquisition,
calibration, full-mode/deployed or deferred assurance claim. No game reset,
played-save conversion or schema change.
[Canonical current contract](LEADER_SKILLS_RUNTIME.md#world-skills-and-selected-decks--17-october-2026).
## 17 October 2026 — Skills/cards with original phase modules
Eight original normal card/Skills phase starts and two plain paired starts
+13 -10
View File
@@ -1,24 +1,27 @@
# Basic Ixians with Leader Skills
Connected development prototype, 21 September 2026. Genuine Basic Ixian setup
now supports the full fourteen-card Leader Skills deck, optionally alongside
Tleilaxu and base factions. Public starts and completion/publication gates remain
closed. Existing missing skill bands are not certified by this admission.
supports all fourteen Skills; normal starts are labelled previews, while
complete-mode/publication acceptance remains gated. The
[17 October selected-deck/World cutover](LEADER_SKILLS_RUNTIME.md#world-skills-and-selected-decks--17-october-2026)
supersedes the old single-family/no-module admission below, not printed powers.
**4 October Advanced continuation:** native Ixians and/or CHOAM with classic
opponents, distinct required family decks and all fourteen skills now compose
under the [bounded native contract](LEADER_SKILLS_RUNTIME.md#advanced-native-ixian-and-choam-composition--4-october-2026).
Advanced Ixian support, skilled Suk rescue and subsequent native substitution
use original physical counters. Basic Tleilaxu admission below is unchanged;
this Advanced subset does not admit foreign gholas or optional overlays.
use original physical counters. Foreign-ghola revival remains Advanced-only
under native Tleilaxu conditions; current optional scope follows17 October above.
## Setup and configuration
Use Basic rules, exactly Ixians & Tleilaxu, two through six players including
Ixians, and no other optional modules. The real private Ixian starting-card
choice precedes simultaneous private skill offers, then public assignments,
Traitors and native Face Dancers. The first Storm retains actual HMS placement
and all seven cyborgs/thirteen suboids. No dealt card determines admission.
Use Basic or Advanced2–6 with the selected Ix deck; other selected E1/E2 and
classic seats follow the shared selected-deck contract. A native Ixian seat is
not required merely to select its ordinary deck. Existing supported World,
Tech/Advanced Strongholds and independent-card combinations use the canonical
scope above. The real private Ixian starting-card choice still precedes skill
offers/assignments, Traitors and Face Dancers; the first Storm retains actual
HMS placement and all seven Cyborgs/thirteen Suboids.
Create and ready a fresh local lobby, then follow the backed-up version-checked
[prototype entry](IX_PROTOTYPE.md) using profile `leader-skills`.
+57
View File
@@ -7,6 +7,63 @@ and fixture opt-ins are removed; existing games are not retrofitted.
Earlier dated references to a separate entry describe historical checkpoints.
See [current activation and limits](SPICE_BANKER_RUNTIME.md#mandatory-ordinary-skills-default--17-october-2026).
## World Skills and selected decks — 17 October 2026
Fresh ordinary all14 setup now validates selected ordinary decks and actual
seats once for Basic/Advanced2–6. Registered classic/E1/E2 seats retain their
required family selection; neither a native expansion seat nor independent
cards is required. Unused selected Ecaz metadata creates no Ecaz/Moritani power.
`selectedDeckLeaderSkillsProfile` replaces the separate Basic-family and
Advanced-native aliases. Foreign-ghola custody still explicitly requires
Advanced and the original native revival conditions; Basic gains no such power.
The independent three-card variant now composes classic or selected E1/E2
Homeworlds/all14, optionally original Tech3+ and/or Advanced Strongholds2+.
All selected known family decks remain authoritative, including unused Ecaz
metadata; actual E3 natives with World cards, Nexus/Discovery and unrelated
role/effect guards remain outside this new envelope. Original hand/draft/cache,
training, typed counters, mandatory normal Banker and phase-module owners stay.
No new saved state, printed arithmetic or played-game conversion is introduced.
World revival uses the accepted current-population invoice and recorded prior
returns. Recruits does not double a separate income ledger or cap native
Tleilaxu at seven. Normal Banker qualifies on the accepted payer contribution,
not a wallet delta after separate native income; Axlotl pays only at phase end,
while Banker waits for actual Mentat. Bulk/split repricing keeps its existing
explicit provisional timing.
Own-World fee counters cannot also fund the dial or Suk rescue. Original
pre-fee traits remain sealed; only committed battle losses can be rescued,
and visitor Harass returns retain their native typed destinations. Every
own-World Harass ban remains. Earned Arrakeen support and Banker strength
create no reserve-fee subsidy. Richese must still retain three actual reserves
after projected materialization and before any later withdrawal.
`tests/selected-deck-skills-runtime.test.ts` uses actual moduleOption/ready/start,
paid shipment and battle, including both real poisoned-disc deaths before
Basic-forbidden/Advanced-legal foreign revival. Existing native/classic World
Recruits, battle and marker fixture/test pairs add default-off original setup
training and meaningful income/source/return cases. Custom seat IDs are retained;
only undealt lotteries and explicitly conserved original card inputs are
controlled, never counters, money, owners or elapsed phases.
Frozen61-file **683/683**, types/lint/build and actual rule programmes pass.
Eight World card/Skills phase starts and five bare selected-deck starts rejected
before and pass after. Actual own-Ix Suk returns only its two committed Cyborgs;
fee1N2E stays in Tanks and exported troops stay separate. Actual Richese funded
marker5 pays three then returns four; capped5/stock3 rejects R and legal H
returns one/loses two. A Basic Ix return programme preserves separate Axlotl and
Banker stores. The regressions additionally cover accepted Tleilaxu invoice5
versus net wallet4, unlimited11 returns and original typed visitor aftercare.
Two unstaged normal World tables exercise Easy/Medium/Hard/Brutal participation:
Basic Ix reaches turn3; Advanced classic wins at turn1 after two battles.
Actual390px normal five-module entry trains an offered disc, keeps a Traitor
and reaches Storm with Kaitain/Salusa and Stronghold inspectors, no app errors
or root overflow. These are changed-path proofs, not full combinations,
strategic calibration, complete-mode, deployed or deferred assurance acceptance.
## Independent cards with original phase modules — 17 October 2026
Fresh original all14 Skills plus independent Recruits/Reinforcements/Harass now
+3 -3
View File
@@ -668,11 +668,11 @@ 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 / all14 Skills | Printed E3 variant independence composes fresh classic, selected E1/E2 native/base or standalone/paired Ecaz/Moritani/classic all14, Basic/Advanced2–6, with original Tech3+ and/or Advanced Strongholds2+. Original36/38/50, cache, native setup and custody stay. Earned support is not personal income or reserve-fee funding; normal Banker and phase-end Tech remain separate. E3 Advanced Harkonnen, shared Duke, mixed E3, World/Nexus/Discovery and effect-specific guards remain. No new numeric rule or save retrofit. | [Current original phase composition and evidence](LEADER_SKILLS_RUNTIME.md#independent-cards-with-original-phase-modules--17-october-2026), [physical card sources](ECAZ_TREACHERY_RULES.md) |
| Independent Treachery / all14 Skills | Printed E3 variant independence composes fresh classic, selected E1/E2 native/base or standalone/paired Ecaz/Moritani/classic all14, Basic/Advanced2–6, with original Tech3+ and/or Advanced Strongholds2+. Classic/E1/E2 also retains original Homeworlds; unused Ecaz selection is not a native E3 seat. Original36/38/50, cache, setup/custody, separate Banker/Tech, own-World fee/dial disjointness, committed-only Suk and every own-World H ban remain. Actual E3 World cards, Nexus/Discovery, Advanced E3/Harkonnen, shared Duke, mixed E3 and effect-specific guards stay separate. No new numeric rule or save retrofit. | [Current World/selected-deck composition](LEADER_SKILLS_RUNTIME.md#world-skills-and-selected-decks--17-october-2026), [physical card sources](ECAZ_TREACHERY_RULES.md) |
| Fresh native Ecaz/Moritani defaults | Fresh plain/card Ecaz constructors initialize printed coalition participation in Basic/Advanced. Supported native Advanced Moritani constructors initialize Assassinate Leaders without requiring Nexus; no-Skills selected native rosters include existing Harkonnen/Ecaz/native-family and Homeworld occupation compositions, while Skills retains its Advanced Harkonnen and standalone/pair profile boundaries. Basic never gains assassination. Existing game-long forfeiture and opportunity history are not invented in played saves. This repairs default initialization, not a new printed rule or complete-mode acceptance. | [Ecaz scope](ECAZ_OCCUPY_RULES.md#standalone-ecaz-leader-skills--17-october-2026), [Moritani authority and scope](MORITANI_ASSASSINATE_LEADERS.md#connected-scope-and-private-information) |
| Independent Treachery / original phase modules | Printed variant independence permits original Tech3–6 Basic/Advanced and/or Strongholds2–6 Advanced with classic/selected E1/E2 card rosters including their Homeworlds. Without Worlds, supported all14/classic/E1/E2/standalone-paired E3 now composes the same original modules. Actual first-Storm/end-Mentat owners, separate delayed income and card funding/physical pools stay; fees/H returns are not revival/shipment and +2 is not supported force. No new priority ruling. Other World/Nexus/Discovery and mixed-E3 guards remain. | [Original phase contract](ECAZ_TREACHERY_RULES.md#original-phase-module-composition--16-october-2026), [current all14 phase composition](LEADER_SKILLS_RUNTIME.md#independent-cards-with-original-phase-modules--17-october-2026), [native World composition](ECAZ_TREACHERY_RULES.md#native-world-phase-modules--17-october-2026) |
| Independent Treachery / original phase modules | Printed variant independence permits original Tech3–6 Basic/Advanced and/or Strongholds2–6 Advanced with classic/selected E1/E2 card rosters including Homeworlds, optionally all14 Skills. Without Worlds, standalone/paired E3 all14 retains the same phase modules. Original first-Storm/end-Mentat owners, separate delayed income and physical card funding stay; fees/H returns are not revival/shipment and +2 is not supported force. No new priority ruling. Actual E3 World cards, Nexus/Discovery and mixed-E3 guards remain. | [Original phase contract](ECAZ_TREACHERY_RULES.md#original-phase-module-composition--16-october-2026), [current all14 World composition](LEADER_SKILLS_RUNTIME.md#world-skills-and-selected-decks--17-october-2026), [native World composition](ECAZ_TREACHERY_RULES.md#native-world-phase-modules--17-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; original Tech/Advanced Strongholds follow the native World phase contract. 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), [phase modules](ECAZ_TREACHERY_RULES.md#native-world-phase-modules--17-october-2026) |
| Selected E1/E2 Worlds / independent cards | Use original selected38/50 native/base Basic/Advanced setup, optionally all14 Skills and original Tech/Advanced Strongholds. Retain current-rate/unlimited revival, accepted invoices versus separate native income, native-only World declarations and every own-World H ban. R normal-first sources retain pre-fee face/traits and fee/dial disjointness. Richese validates3 AFTER projected materialization before a first seal, never from later H returns or skill strength. Only committed battle losses feed Suk and actual board Cyborg losses feed substitution; fee sources are never duplicated. Existing timing remains provisional, not a new FAQ. | [Original native funding](ECAZ_TREACHERY_RULES.md#native-selected-family-world-composition--17-october-2026), [current Skills composition](LEADER_SKILLS_RUNTIME.md#world-skills-and-selected-decks--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) |
+15 -14
View File
@@ -1,17 +1,18 @@
# Tleilaxu with Leader Skills
Connected development prototype, 21 September 2026. Basic Tleilaxu with base
opponents now uses all fourteen physical skills through genuine setup. Public
starts, complete-mode acceptance and publication remain gated. Existing missing
skill effects are still explicit; this is not complete expansion certification.
Connected development prototype, 21 September 2026. Genuine Tleilaxu setup uses
all fourteen physical skills. Normal starts are labelled previews, not complete
mode/publication acceptance. The
[17 October selected-deck/World scope](LEADER_SKILLS_RUNTIME.md#world-skills-and-selected-decks--17-october-2026)
supersedes the original single-family/no-module admission, not native powers.
## Advanced native composition — 4 October 2026
Fresh local Advanced `leader-skills` tables now admit native Tleilaxu, optionally
Ixians and/or CHOAM, with classic opponents and distinct required `ix`/`choam`
decks. Two through six unique seats retain all fourteen physical skills,
starting-card choices, real Face Dancers and native setup. Existing Basic
entries below remain unchanged; public starts and unrelated overlays stay gated.
starting-card choices, real Face Dancers and native setup. The current shared
selected-deck contract also serves Basic; foreign gholas remain Advanced-only.
Actual half-price foreign revival fills the controlled living pool only up to
five. The original faction retains the sole physical disc; `gholaBy` records
@@ -51,14 +52,14 @@ board counters after refresh; the detailed guide is visually readable at390px.
## Configuration and physical setup
The existing `leader-skills` local initializer accepts Basic rules, exactly the
Ixians & Tleilaxu expansion, Tleilaxu plus base factions, two through six seats,
and no other optional modules. It keeps the real Ix Treachery/Spice decks and
the Cheap Hero Traitor identity. Starting Treachery precedes simultaneous
private two-card skill offers; public assignments and ordinary Traitor choices
precede the native three Face Dancers. No hidden card controls profile admission.
Ixians have a separate [integration](IX_LEADER_SKILLS.md); the Advanced native
composition above supersedes the earlier admission boundary, not other modules.
The ordinary `leader-skills` setup accepts Basic/Advanced2–6 with the selected
Ix deck and Tleilaxu, optionally other selected E1/E2/classic seats. Independent
cards are not required. The current shared contract above records supported
World/Tech/Advanced Strongholds/card combinations. It retains the original Ix
Treachery/Spice decks, Cheap Hero Traitor identity and native three Face Dancers.
Starting Treachery precedes private skill offers, assignments and Traitors;
no hidden card determines admission. Ixians keep their
[native integration](IX_LEADER_SKILLS.md); Basic never gains foreign revival.
## Connected interactions
+9 -6
View File
@@ -1,7 +1,7 @@
import { classicNexusModulesProfile, mixedE1E2NexusProfile, mixedE3NexusProfile, pairedE3NexusProfile, pairedNexusModulesProfile, singleE1E2NexusProfile, standaloneE3NexusProfile } from './nexus-module-profile';
import { nativeFactionTechProfile } from './faction-module-profile';
import { classicDiscoveryLeaderSkillsProfile, classicDiscoveryNexusProfile, homeworldDiscoveryProfile, discoveryModeSupported, discoveryModuleProfile, mixedE1E2DiscoveryNexusProfile, mixedE3DiscoveryNexusProfile, pairedDiscoveryNexusProfile, pairedE3DiscoveryNexusProfile, singleE1E2DiscoveryNexusProfile, standaloneE3DiscoveryNexusProfile } from './discovery-module-profile';
import { advancedMoritaniLeaderSkillsProfile, advancedNativeLeaderSkillsProfile, homeworldLeaderSkillsProfile, independentTreacheryLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, guildBetrayalNexusLeaderSkillsProfile, mixedE1E2NexusLeaderSkillsProfile, mixedE3NexusLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeEcazLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, pairedE3LeaderSkillsProfile, pairedE3NexusLeaderSkillsProfile, pairedNexusLeaderSkillsProfile, singleE1E2NexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile } from './leader-skill-profile';
import { advancedMoritaniLeaderSkillsProfile, selectedDeckLeaderSkillsProfile, homeworldLeaderSkillsProfile, independentTreacheryLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, guildBetrayalNexusLeaderSkillsProfile, mixedE1E2NexusLeaderSkillsProfile, mixedE3NexusLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeEcazLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, pairedE3LeaderSkillsProfile, pairedE3NexusLeaderSkillsProfile, pairedNexusLeaderSkillsProfile, singleE1E2NexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile } from './leader-skill-profile';
import { bribeTimingBlock, maximumBribe, type BribeOptions } from './bribe-options';
import { quoteSpicePlacement, stormExposesTerritory, stormSectorAfter, wormConsumesForces } from './disaster-rules';
import { isStormCardDistance, type StormCardComponent } from './storm-cards';
@@ -8805,7 +8805,7 @@ function leaderSkillsIntegrity(g: Game) {
if (leader.gholaBy === undefined) continue;
const controller = g.players.find((p) => p.id === leader.gholaBy);
requireRule(
(foreignProfileAllowed ??= advancedNativeLeaderSkillsProfile(g)) &&
(foreignProfileAllowed ??= g.advanced && selectedDeckLeaderSkillsProfile(g)) &&
controller?.faction === 'tleilaxu' &&
controller.id !== owner.id &&
leader.faction === owner.faction &&
@@ -10708,7 +10708,7 @@ export function initializeLeaderSkillsGameForAudit(state: Game): Game {
const homeworlds = homeworldLeaderSkillsProfile(state);
const independent = independentTreacheryLeaderSkillsProfile(state);
if (state.ecazTreachery) {
requireRule(independent, 'Independent Treachery with Leader Skills requires a fresh supported classic or native lobby with only optional original Tech Tokens and Advanced Stronghold Cards.');
requireRule(independent, 'Independent cards with Skills require a fresh supported selected-deck roster with original Homeworlds/Tech/Advanced Strongholds; Nexus/Discovery and native E3 Worlds remain gated.');
requireFreshBaseRuntime(state);
requireFreshFactionInventory(state);
}
@@ -10749,8 +10749,10 @@ function independentCardRoster(g: Game): boolean {
});
}
function ecazHomeworldProfile(g: Game): boolean {
if (g.homeworlds && g.leaderSkills && !g.sandtrout && independentTreacheryLeaderSkillsProfile(g)) return true;
return !!g.homeworlds && g.ecazTreachery === true && independentCardRoster(g) &&
!g.expansions.includes('ecaz') && g.players.length >= 2 && g.players.length <= 6 &&
!g.players.some(player => player.faction === 'ecaz' || player.faction === 'moritani') &&
g.players.length >= 2 && g.players.length <= 6 &&
!g.nexusCards && !g.leaderSkills &&
((!g.techTokens && !g.strongholdCards) || independentCardModulesProfile(g)) &&
!g.discoveryEnabled && !g.discoveries && !g.discoveryStash && !g.greatMaker && !g.sandtrout &&
@@ -10763,7 +10765,8 @@ function ecazHomeworldProfile(g: Game): boolean {
function independentCardModulesProfile(g: Game): boolean {
if ((g.techTokens || g.strongholdCards) && g.leaderSkills && !g.sandtrout &&
independentTreacheryLeaderSkillsProfile(g)) return true;
if (!g.ecazTreachery || (!g.techTokens && !g.strongholdCards) || !independentCardRoster(g) || g.expansions.includes('ecaz') ||
if (!g.ecazTreachery || (!g.techTokens && !g.strongholdCards) || !independentCardRoster(g) ||
g.players.some(player => player.faction === 'ecaz' || player.faction === 'moritani') ||
g.players.length < (g.techTokens ? 3 : 2) || g.players.length > 6 ||
(g.strongholdCards && !g.advanced) ||
g.nexusCards || g.leaderSkills ||
@@ -13947,7 +13950,7 @@ function recruitsPlayBlock(g: Game, owner: Player, card: Card): string | null {
if (!g.ecazTreachery || !ecazTreacheryDefinition(card) || card.id !== RECRUITS_CARD_ID)
return 'Choose the physical Recruits card from the enabled Ecaz Treachery Cards variant.';
if (!recruitsModeSupported(g))
return 'Recruits requires a supported independent-card profile: faction-only, all14 Skills with optional original phase modules, or the existing native Homeworld compositions.';
return 'Recruits requires a supported independent-card profile: faction-only, all14 Skills with original World/phase modules, or existing native Homeworld compositions.';
if (g.status !== 'playing' || g.phase !== 4)
return 'Play Recruits during Revival.';
if (g.recruits?.turn === g.turn)
+34 -92
View File
@@ -233,17 +233,17 @@ function semutaLeaderSkillContext(game: LeaderSkillProfile): boolean {
game.expansions.includes('choam') && !!game.players?.some(p => p.faction === 'richese');
}
/** E1/E2 native skills may add Tech and Advanced Strongholds; original roster/decks remain. */
function nativeLeaderSkillModulesSupported(game: LeaderSkillProfile): boolean {
/** Selected ordinary decks retain original module envelopes; actual E3 seats are separate. */
function selectedDeckLeaderSkillModulesSupported(game: LeaderSkillProfile): boolean {
if (game.homeworlds) return homeworldLeaderSkillModulesSupported(game);
if (noOtherLeaderSkillModules(game) || pairedNexusLeaderSkillsProfile(game) ||
if (noOtherLeaderSkillModules(game) || independentTreacheryLeaderSkillsProfile(game) || pairedNexusLeaderSkillsProfile(game) ||
singleE1E2NexusLeaderSkillsProfile(game) || mixedE1E2NexusLeaderSkillsProfile(game)) return true;
if (game.discoveryEnabled || game.discoveries) return discoveryLeaderSkillModulesSupported(game);
const players = game.players;
if ((!game.techTokens && !game.strongholdCards) || !players ||
players.length < (game.techTokens ? 3 : 2) || players.length > 6 ||
(game.strongholdCards && game.advanced !== true) ||
game.expansions.length < 1 || game.expansions.length > 2 ||
game.expansions.length < 1 || game.expansions.length > 3 ||
game.homeworlds || game.nexusCards || game.discoveries || game.discoveryEnabled ||
game.ecazTreachery || (game.semutaPreview && !semutaLeaderSkillContext(game)) ||
game.moritaniAssassinatePreview || game.advancedPreview || game.kullPreview ||
@@ -251,7 +251,7 @@ function nativeLeaderSkillModulesSupported(game: LeaderSkillProfile): boolean {
game.nexusIxianReplacementPreview || game.nexusIxianBetrayalPreview ||
game.nexusHarkonnenBetrayalPreview) return false;
for (const expansion of game.expansions)
if (expansion !== 'ix' && expansion !== 'choam') return false;
if (expansion !== 'ix' && expansion !== 'choam' && expansion !== 'ecaz') return false;
for (let i = 0; i < players.length; i++)
for (let j = 0; j < i; j++) if (players[j].faction === players[i].faction) return false;
return true;
@@ -303,7 +303,7 @@ export function classicTechLeaderSkillsProfile(game: LeaderSkillProfile): boolea
export function strongholdLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
if (!game.strongholdCards || game.advanced !== true) return false;
if (independentTreacheryLeaderSkillsProfile(game)) return true;
if (game.expansions.length) return advancedNativeLeaderSkillsProfile(game) ||
if (game.expansions.length) return selectedDeckLeaderSkillsProfile(game) ||
advancedMoritaniLeaderSkillsProfile(game) || nativeEcazLeaderSkillsProfile(game);
const players = game.players;
if (!players || players.length < (game.techTokens ? 3 : 2) || players.length > 6 ||
@@ -386,102 +386,44 @@ export function nativeEcazLeaderSkillsProfile(game: LeaderSkillProfile): boolean
return native;
}
/** Native Basic Tleilaxu, including Face Dancers; foreign gholas are Advanced. */
export function basicTleilaxuLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
return game.advanced === false && game.expansions.length === 1 &&
game.expansions[0] === 'ix' && nativeLeaderSkillModulesSupported(game) &&
!!game.players?.some(player => player.faction === 'tleilaxu') &&
game.players.every(player => player.faction === 'tleilaxu' ||
FACTIONS.some(faction => faction.id === player.faction && faction.expansion === 'base'));
}
/** Basic Ixians, optionally with native Tleilaxu, keep the ordinary Ix inventories. */
export function basicIxLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
return game.advanced === false && game.expansions.length === 1 &&
game.expansions[0] === 'ix' && nativeLeaderSkillModulesSupported(game) &&
!!game.players?.some(player => player.faction === 'ixians') &&
game.players.every(player => ['ixians', 'tleilaxu'].includes(player.faction) ||
FACTIONS.some(faction => faction.id === player.faction && faction.expansion === 'base'));
}
/** Basic CHOAM's ordinary leaders and economy; Auditor belongs to Advanced. */
export function basicChoamLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
return game.advanced === false && game.expansions.length === 1 &&
game.expansions[0] === 'choam' && nativeLeaderSkillModulesSupported(game) &&
!!game.players?.some(player => player.faction === 'choam') &&
game.players.every(player => player.faction === 'choam' ||
FACTIONS.some(faction => faction.id === player.faction && faction.expansion === 'base'));
}
/** Native Basic Richese/CHOAM setup, without unrelated optional overlays. */
export function basicRicheseLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
/** Base/E1/E2 seats with selected ordinary decks, Basic or Advanced; no faction prerequisite. */
export function selectedDeckLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
const players = game.players;
if (game.advanced !== false || game.expansions.length !== 1 ||
game.expansions[0] !== 'choam' || !players || players.length < 2 || players.length > 6 ||
!nativeLeaderSkillModulesSupported(game) ||
(game.semutaPreview && !semutaLeaderSkillContext(game)) || game.moritaniAssassinatePreview ||
game.advancedPreview || game.kullPreview || game.nexusKullPreview ||
game.guildBetrayalPreview || game.richeseBetrayalPreview ||
game.nexusIxianReplacementPreview || game.nexusIxianBetrayalPreview ||
game.nexusHarkonnenBetrayalPreview) return false;
let native = false;
for (let i = 0; i < players.length; i++) {
const player = players[i];
for (let j = 0; j < i; j++) if (players[j].faction === player.faction) return false;
if (player.faction === 'richese') native = true;
else if (player.faction !== 'choam') {
let classic = false;
for (const faction of FACTIONS)
if (faction.id === player.faction && faction.expansion === 'base') { classic = true; break; }
if (!classic) return false;
}
}
return native;
}
export function basicExpansionLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
return basicMoritaniLeaderSkillsProfile(game) || basicTleilaxuLeaderSkillsProfile(game) ||
basicIxLeaderSkillsProfile(game) || basicChoamLeaderSkillsProfile(game) || basicRicheseLeaderSkillsProfile(game);
}
/** Source-clear Advanced native factions; optional decks never imply a seated faction. */
export function advancedNativeLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
const players = game.players;
if (game.advanced !== true || !players || players.length < 2 || players.length > 6 ||
game.expansions.length < 1 || game.expansions.length > 2 ||
(game.expansions.length === 2 && game.expansions[0] === game.expansions[1]) ||
!nativeLeaderSkillModulesSupported(game) ||
if (typeof game.advanced !== 'boolean' || !players || players.length < 2 || players.length > 6 ||
game.expansions.length < 1 || game.expansions.length > 3 ||
!selectedDeckLeaderSkillModulesSupported(game) ||
(game.semutaPreview && !semutaLeaderSkillContext(game)) ||
game.moritaniAssassinatePreview || game.advancedPreview ||
game.kullPreview || game.nexusKullPreview ||
game.guildBetrayalPreview || game.richeseBetrayalPreview ||
game.nexusIxianReplacementPreview || game.nexusIxianBetrayalPreview ||
game.nexusHarkonnenBetrayalPreview) return false;
for (const expansion of game.expansions)
if (expansion !== 'ix' && expansion !== 'choam') return false;
let native = false;
for (let i = 0; i < game.expansions.length; i++) {
const expansion = game.expansions[i];
if (expansion !== 'ix' && expansion !== 'choam' && expansion !== 'ecaz') return false;
for (let j = 0; j < i; j++) if (game.expansions[j] === expansion) return false;
}
for (let i = 0; i < players.length; i++) {
const player = players[i];
// Six seats at most: check prior seats without allocating per-rule quote sets.
for (let j = 0; j < i; j++) if (players[j].faction === player.faction) return false;
if (player.faction === 'ixians' || player.faction === 'tleilaxu' ||
player.faction === 'choam' || player.faction === 'richese') {
if (!game.expansions.includes(player.faction === 'choam' || player.faction === 'richese' ? 'choam' : 'ix')) return false;
native = true;
} else {
let classic = false;
for (const faction of FACTIONS)
if (faction.id === player.faction && faction.expansion === 'base') { classic = true; break; }
if (!classic) return false;
}
let selected = false;
for (const faction of FACTIONS)
if (faction.id === player.faction) {
selected = faction.expansion === 'base' ||
((faction.expansion === 'ix' || faction.expansion === 'choam') &&
game.expansions.includes(faction.expansion));
break;
}
if (!selected) return false;
}
return native;
return true;
}
export function nativeExpansionLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
return (game.expansions.length > 0 && independentTreacheryLeaderSkillsProfile(game)) ||
mixedE3NexusLeaderSkillsProfile(game) || mixedE1E2NexusLeaderSkillsProfile(game) || pairedE3NexusLeaderSkillsProfile(game) ||
basicExpansionLeaderSkillsProfile(game) || advancedNativeLeaderSkillsProfile(game) ||
basicMoritaniLeaderSkillsProfile(game) || selectedDeckLeaderSkillsProfile(game) ||
advancedMoritaniLeaderSkillsProfile(game) || nativeEcazLeaderSkillsProfile(game);
}
@@ -506,7 +448,7 @@ function homeworldLeaderSkillModulesSupported(game: LeaderSkillProfile): boolean
!standaloneE3NexusLeaderSkillsProfile(game)) ||
(game.discoveryEnabled !== true && (game.discoveryEnabled || game.discoveries || game.discoveryStash || game.greatMaker)) ||
(game.techTokens && players.length < 3) || (game.strongholdCards && !game.advanced) ||
game.ecazTreachery ||
(game.ecazTreachery && !independentTreacheryLeaderSkillsProfile(game)) ||
game.semutaPreview || game.advancedPreview ||
(game.moritaniAssassinatePreview && (!game.advanced || game.expansions.length !== 1 ||
game.expansions[0] !== 'ecaz' || !players.some(player => player.faction === 'moritani'))) ||
@@ -535,18 +477,18 @@ export function homeworldLeaderSkillsProfile(game: LeaderSkillProfile): boolean
/** Independent cards with classic, selected E1/E2 or standalone/paired E3 all14 setup. */
export function independentTreacheryLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
const e3 = game.expansions.length === 1 && game.expansions[0] === 'ecaz';
if (game.ecazTreachery !== true || game.expansions.length > 2 ||
if (game.ecazTreachery !== true || game.expansions.length > 3 ||
typeof game.advanced !== 'boolean' || !game.players ||
game.players.length < (game.techTokens ? 3 : 2) || game.players.length > 6 ||
(game.strongholdCards && game.advanced !== true) ||
game.homeworlds || game.nexusCards || game.discoveries || game.discoveryEnabled ||
game.nexusCards || game.discoveries || game.discoveryEnabled ||
game.discoveryStash || game.greatMaker ||
game.semutaPreview || game.advancedPreview ||
game.kullPreview || game.nexusKullPreview || game.guildBetrayalPreview ||
game.richeseBetrayalPreview || game.nexusIxianReplacementPreview ||
game.nexusIxianBetrayalPreview || game.nexusHarkonnenBetrayalPreview) return false;
for (let i = 0; i < game.expansions.length; i++) {
if (!e3 && game.expansions[i] !== 'ix' && game.expansions[i] !== 'choam') return false;
if (game.expansions[i] !== 'ix' && game.expansions[i] !== 'choam' && game.expansions[i] !== 'ecaz') return false;
for (let j = 0; j < i; j++) if (game.expansions[j] === game.expansions[i]) return false;
}
const players = game.players;
@@ -562,9 +504,9 @@ export function independentTreacheryLeaderSkillsProfile(game: LeaderSkillProfile
}
if (!selected) return false;
}
// Preserve the native Advanced Skills/Harkonnen and shared-Duke boundaries.
if (e3 && nativeE3 && game.advanced &&
players.some(player => player.faction === 'harkonnen')) return false;
// Actual E3 seats retain their one-family, non-World and Advanced Harkonnen boundaries.
if (nativeE3 && (!e3 || game.homeworlds ||
(game.advanced && players.some(player => player.faction === 'harkonnen')))) return false;
if (game.moritaniAssassinatePreview && (!e3 || !game.advanced ||
!players.some(player => player.faction === 'moritani'))) return false;
return true;
+11 -11
View File
@@ -3415,7 +3415,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'The mobile card copies a currently controlled stronghold, even if another faction still holds that location’s card. Holding a card after leaving its location does not itself make that location an eligible copy. The choice is public and fixed before battle plans; a sole eligible advantage is announced automatically.',
'Arrakeen pays only for actual support and creates no personal cash. CHOAM force-payment income includes the bank contribution, subject to its normal traitor and donor exclusions. Carthag does not protect an empty or Worthless defense, stop Poison Tooth, or remove a Shield’s Lasgun explosion risk.',
'Sietch Tabr uses the opposing dial, excluding leaders and other bonuses. A single traitor victory qualifies; mutual destruction does not. Tuek pays after an ordinary loss or a single traitor outcome as well as a win; a double traitor gives neither faction spice. Habbanya also decides a tied Stone Burner comparison.',
'Fresh Advanced classic or supported E1/E2 native all14 Skills retains original Strongholds2–6 and optional Tech3–6. Independent cards may also be selected within their No-World/Nexus/Discovery envelope. Original deck/cache and real end-Mentat claims remain. Arrakeen funds actual support, not personal income or reserve fees; only the final actual payer debit qualifies for mandatory normal Banker income. Rescue preserves the support charge, then card cleanup and Tech finish before Face Dance. Specific copied-defense/skill-Stone and unrelated module guards remain; no save conversion.',
'Fresh Advanced classic or supported E1/E2 all14 Skills retains original Strongholds2–6 and optional Tech3–6, including its independent-card/World envelope. Native E3 card Worlds and Nexus/Discovery combinations remain separate. Original deck/cache and actual end-Mentat claims stay. Arrakeen funds only local holder-plan support, never native World support, personal cash or reserve fees; normal Banker uses the final actual payer debit. Rescue preserves support, then cleanup and Tech precede Face Dance. Specific copied-defense/skill-Stone and unrelated guards remain.',
'Standalone or paired Ecaz/Moritani Skills retains these original Advanced phase modules, optionally independent cards, with exact ecaz33/36, native five-disc training and original roster exclusions. Normal Warmaster one on a temporary Duke never grants trained three. Winner Suk precedes unused-disc assassination, printed bounty, winner Tech and one Mentat replacement. Ecaz-held support belongs only to the selected Ecaz plan, not an allied Moritani plan; fixed and variable armies stay separate. Mixed E3 families and shared-Duke assignment remain guarded.',
'Fresh independent Treachery previews compose classic or selected Ix/CHOAM native/base rosters, including original Homeworlds, with Advanced Strongholds2–6 and optional Tech3–6. Actual end-Mentat earns custody. Reinforcements adds no supported counters; Harass vacates control without transferring a retained card early. Exact native fee/return, original card effects and winner Tech order remain; other overlays stay guarded.',
'The six effects, public ownership and pre-plan choice are integrated in development games. Enable Stronghold Cards in an Advanced preview lobby. Complete Advanced compliance and every expansion interaction remain under verification.',
@@ -3523,7 +3523,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Fremen reserve arrivals and Guild cross-planet or return shipments do not trigger Heighliners. A stopped shipment never arrives and cannot trigger it. Bene Gesserit accompanying forces do arrive from off planet and can trigger the token even after a Guild shipment.',
'A battle winner takes one tech token from the defeated faction after casualties and played-card decisions. With several available tokens, the winner chooses. A winner still takes the token if no winning forces survive. Mutual destruction has no winner and transfers no token.',
'Owning all three tokens counts as one additional stronghold at victory checks. An alliance cannot combine separate token holdings to qualify: one member must own the complete set. Multiple sides reaching the victory target can share the win.',
'Token ownership and pending income are public and persist. Fresh classic, E1/E2 native or standalone/paired Ecaz/Moritani all14 Skills retains original Tech3–6 Basic/Advanced; Advanced may add Strongholds2–6, with independent cards within the No-World/Nexus/Discovery envelope. Rescue or board-spice awards precede winner Tech and later Face Dance. Mandatory normal Banker waits for real Mentat, separate from phase-end Tech; fees and Harass are neither free revival nor off-planet shipment. Native CHOAM opening income is not poverty Charity. Two-player Tech, mixed E3 and full combined acceptance remain guarded.',
'Token ownership and pending income are public and persist. Fresh classic, selected E1/E2 or standalone/paired E3 all14 keeps original Tech3–6 Basic/Advanced and optional Advanced Strongholds2–6. Classic/E1/E2 Worlds may also select the independent cards; native E3 card Worlds and Nexus/Discovery retain their own guards. Rescue/board-spice precedes winner Tech and Face Dance. Mandatory normal Banker waits for Mentat, separate from phase-end Tech; fees/H returns are not revival/shipment. Native CHOAM opening income is not poverty Charity. Two-player Tech and full combined acceptance remain unfinished.',
'Without Skills, fresh native E1/E2 families or standalone Ecaz OR Moritani with classic opponents may preserve unused original Tech Tokens through the local factions entry, Basic/Advanced three through six players; Advanced stronghold-factions adds the original six Stronghold Cards. Native payments, typed casualties, selected Ecaz lead rewards and Moritani original assassination remain separate. Split allied holdings never qualify as a complete set. Basic odd Occupy, Richese cache/No-Field rulings, unrelated overlays, public starts and saved-game conversion remain guarded.',
'Classic or selected Ix/CHOAM native/base independent-card previews, including Homeworlds, retain unused Tech3–6 Basic/Advanced and optional Advanced Strongholds2–6. Recruits changes current native rates without repeating Axlotl or advancing payout. Harass deposits and reserve fees are not free Revival or off-planet shipment. Original phase income/native custody/winner rewards remain; other overlays stay separate.',
],
@@ -4064,9 +4064,9 @@ export const RULE_TOPICS: RuleTopic[] = [
).join(' '),
steps: [
'Homeworlds are an optional module from Ecaz & Moritani, available under the official rules with Basic or Advanced play. They are separate locations outside Arrakis; they do not receive ordinary storm movement or count as ordinary strongholds.',
'Fresh private Homeworld prototypes retain all fourteen Skills, Discovery7/8 or both in classic or supported E1/E2 and standalone Ecaz OR Moritani tables. Original Nexus entries additionally support single or paired one-family E1/E2, natives from BOTH selected Ix+CHOAM families with union47, and standalone OR paired Ecaz/Moritani/ecaz33 with classics, with or without Skills. Basic/Advanced2–6, Tech3+ and Advanced Strongholds2+ retain original setup, native placement, phase-end industry and source-local held cards. Advanced Moritani/paired E3 excludes Harkonnen; otherwise legal standalone no-Skills Ecaz/Harkonnen does not lift its separate Duke Cunning exclusion. Winning typed native Suk saves stay at that home; external Emperor returns allocate physical normal/elite counters to original homes as the existing interpretation. Normal revival separately deposits Sardaukar at Salusa. Nested battles receive no Homeworld bonus. Mixed E3/three-family Nexus, pending effects, public starts and played-save conversion remain separate.',
'Fresh classic/E1/E2 World all14 uses actual registered seats and selected ordinary decks in Basic/Advanced; no expansion faction or independent card is required to unlock training. Independent cards may join original Tech3+/Advanced Strongholds2+ in this envelope. Original no-card Nexus/Discovery and native E3 World profiles retain their separate roster/effect limits. Actual starting hands, Ix card-before-training and ordinary native discs remain; Auditor/shared Duke never train. Typed native Suk saves stay at that home; external Emperor returns allocate original Kaitain/Salusa destinations. No nested location gains an ordinary Stronghold benefit or saved history by default.',
'Each faction uses its own card and matching world token. In Basic play the Emperor uses Kaitain alone. Advanced play adds Salusa Secundus for its Sardaukar; movement can transfer both force types between the two Emperor worlds.',
'Fresh classic Basic/Advanced independent36-card/Homeworld setup connects all three bounded effects: Harass returns actual typed undialed forces through native custody, never on an own World; Recruits doubles current native rates with provisional separate-request thresholds; Reinforcements seals exact native reserve fee sources and excludes an on-World fee from physical dial/casualties while preserving original face/support. Other rosters/overlays, whole-mode acceptance and public starts remain guarded.',
'Fresh supported classic/E1/E2 Worlds compose original all14 and the independent36/38/50-card setup, optionally Tech3+/Advanced Strongholds2+. Recruits observes current native rates/typed limits and recorded paid/free use; Banker and phase-end Axlotl remain separate. Reinforcements seals exact normal-first native sources and excludes an own-World fee from physical dial/casualties while preserving original face/support. Suk rescues committed losses, not fees. Harass returns actual visitors through native custody and is forbidden on every own World. Native E3, Nexus/Discovery and other effect-specific guards remain.',
'Native reserves determine high or low population. Salusa counts Sardaukar, while the other cards count the faction’s reserves there. Reaching the high face’s minimum restores high population. Occupation also applies the low penalty.',
'Low population normally grants one additional free force revival and one additional bank spice when collecting CHOAM Charity. Salusa grants neither bonus; CHOAM’s Tupile reverses the usual pattern by granting an advantage while low and a penalty while high.',
'A qualifying charity claim separates its ordinary payer from the extra bank spice. Inflation doubles both portions or prevents the whole collection. Canceling Bene Gesserit charity prevents both portions of that claim.',
@@ -4927,12 +4927,12 @@ export const RULE_TOPICS: RuleTopic[] = [
developmentStage: 'Prototyped',
summary: 'Fourteen physical skills, private assignment choices, five battle disciplines, Planetologist movement/battle effects and Suk Graduate rescues, Mentat/Bureaucrat scoring and Bureaucrat payment redirection, Sandmaster victory spice, Rihani exchanges, optional Smuggler shipping and provisional modifier-inclusive battle collection, Sandmaster route and worm-ride collection, sealed Spice Banker battle spending and optional Diplomat base-defense copying in development tables. Other effects and combined-module acceptance remain unfinished.',
steps: [
'Fresh all14 Leader Skills preserves original classic, supported E1/E2 or standalone Ecaz OR Moritani setup with Homeworlds/Discovery/Tech3+/Advanced Strongholds under each envelope. Explicit --nexus-cards additionally admits single/paired one-family E1/E2, BOTH selected Ix+CHOAM native families/union47 and standalone OR paired Ecaz/Moritani/ecaz33 with classics. Basic/Advanced2–6, original Ix pre-training offer, ordinary native discs and actual hands remain; Auditor/shared Duke never train. Winning normal/trained Suk uses source-labelled native, visitor or nested fixed-loss pools; native saved counters stay at that home and external Emperor returns allocate original homes. Original phase-end industry, held benefits, cleanup/Tech before Face Dance and native assassination remain; unreconciled bands, mixed E3/three-family, public starts and played-save conversion stay separate.',
'Fresh all14 uses one selected-deck capability for registered base/E1/E2 seats, Basic/Advanced, without requiring an expansion faction, independent cards or Nexus merely to use a selected ordinary deck. Native E3 retains its dedicated roster boundaries. Existing World/phase and no-card Nexus/Discovery envelopes keep actual setup and original component custody. Ix chooses its starting card before training; Auditor/shared Duke never train. Native World Suk saves and external Emperor typed destinations stay source-labelled. No new Advanced power, income, module owner or played-save history is created by this configuration cutover.',
'Explicit guild-betrayal --leader-skills retains the original classic native-Guild Nexus profile without Homeworlds, Tech, Strongholds, Discovery, Semuta or other overlays. All14 training remains native. Ordinary skill quotes now admit the composition, restoring actual Mentat private questions before posture and Sandmaster ground collection rather than only enabling Bureaucrat. Native card custody, paid forces, original Betrayal Use/Pass and movement/plan continuation stay unchanged. Other combinations and complete-mode acceptance remain separate.',
'Basic Moritani retains classic opponents including Harkonnen. Standalone or paired Ecaz/Moritani Skills retains original Tech3+ and Advanced Strongholds2+, optionally independent cards, with the original Advanced non-Harkonnen assassination roster. Winner Suk and Rihani finish before an eligible different trained disc dies, returns one skill and pays printed bounty. No casualties repeat; original set-aside and one Mentat replacement remain. Shared-Duke training, mixed families and other module envelopes stay guarded.',
'Fresh Advanced profiles compose native Ixians, Tleilaxu, CHOAM and/or Richese with classic opponents and distinct required family decks. All fourteen skills and native starts remain. Auditor stays unskilled; cyborg casualties, original CHOAM support and Richese capped marker revelation remain physical. Foreign revival creates no new skill. Supplied Advanced p34 excludes Richese auctions from Ixian Technology; own mixed No-Field, captured replacement, Advanced Atreides Suk/KH and unrelated overlays retain their guards.',
'The independent Recruits, Reinforcements and Harass & Withdraw cards compose all14 Skills in fresh classic, selected Ix/CHOAM native/base or standalone/paired Ecaz/Moritani/classic Basic/Advanced games, optionally original Tech3+ and Advanced Strongholds2–6. Original36/38/50, Richese cache, native setup and revival prices stay. Card fee/returns/Suk and Smuggler, Planetologist and Sandmaster share admission. Earned support is not personal spice or reserve fee funding; phase-end Tech and actual-Mentat Banker remain separate. Advanced E3/Harkonnen, mixed E3, World/Nexus/Discovery and other guards remain.',
'Fresh all14 Skills retains original Tech3–6 Basic/Advanced for classic factions, supported E1/E2 or standalone/paired Ecaz/Moritani with their actual selected decks. Cards are optional within the independent-card envelope. Starting hands/cache, skill/Traitor choices and printed/first-Storm owners remain genuine. No token owner, income or victory credit is assigned to simulate earned play. Existing World/Nexus envelopes and mixed E3 or unrelated guards stay separate; played games are not converted.',
'Basic and Advanced selected-deck profiles admit actual Ixians, Tleilaxu, CHOAM/Richese and base seats only from their selected families; unused faction availability never creates a seat or requires one. All14 and native setup remain. Auditor is Advanced and unskilled; foreign-ghola authorization still explicitly requires Advanced, and foreign revival creates no new skill. Cyborg casualties, native funding and capped markers remain physical. Supplied Advanced p34 excludes Richese auctions from Ixian Technology; unrelated captured/KH/copy/role guards retain their own rules.',
'Independent Recruits, Reinforcements and Harass compose all14 in supported classic/E1/E2 and standalone/paired E3 Basic/Advanced, with original Tech3+/Advanced Strongholds2+. Classic/E1/E2 Worlds also retain the cards and phase modules. Original36/38/50, cache, setup and native prices stay. Shared admission feeds card/skill controls; own World fee/dial pools stay disjoint, visitor returns keep typed native homes and Suk cannot rescue fees. Earned support is not personal money or reserve funding. Native E3 card Worlds, Nexus/Discovery, actual mixed E3 and special-role guards remain.',
'Fresh all14 keeps original Tech3–6 Basic/Advanced with actual selected decks, optional independent cards and supported World envelopes. Starting hands/cache, original offers/Traitors and printed/first-Storm owners remain genuine. Native rates use actual stock/population on each accepted request; normal Banker records the accepted Bank invoice, not a net wallet change after separate native income. No token owner, premature spendable grant or victory credit is assigned to simulate earned play. Other Nexus/Discovery, native E3 and effect-specific envelopes stay separate; played games are not converted.',
'Fresh Advanced supported classic/native all14 tables retain six original Stronghold Cards, optional Tech3+, and independent cards within their own envelope. Actual end-Mentat earns the holder; benefit stays local to the selected holder-plan. Arrakeen changes actual payer debit, not physical commitment, Suk entitlement or Banker threshold. Native E1/E2 aftercare and standalone/paired E3 coalition/assassination retain original order. Shared Duke never trains; mixed E3, unsupported copied defenses and active skill/Stone retain their guards.',
'Fresh classic Leader Skills may explicitly add Nexus through local leader-skills --nexus-cards, Basic/Advanced two through six players, retaining selected original Tech at three or more seats and/or Advanced Strongholds. --discoveries additionally composes original Discovery7/8 in this classic profile. Saved hands/offers remain authoritative; the actual end-Spice closing draw follows Maker votes/typed rides, both Advanced piles and settled alliance. Free-return ledgers, bank purchase and Traitor Deck declarations stay distinct from training. Original phase-end industry, held payer support/physical defenses, physical Suk rescue and subsequent mandatory winner Tech rewards retain their own timing. Homeworlds, Banker/Mentat previews, borrowed Smuggler arithmetic and unresolved interactions remain separate.',
'The same fresh entry also adds --nexus-cards --discoveries to paired Ixians+Tleilaxu, paired CHOAM+Richese or standalone Ecaz OR Moritani with classics, original all14/all12/Discovery7/8, Basic/Advanced two through six with optional Tech3+ and Advanced Strongholds. Original offers/hands/native components remain authoritative. E1 Suboid Cunning settles typed Suk rescue before one equal Cyborg Tank substitution, then original cleanup and mandatory winner Tech precede a matching Face Dance; E2 keeps its signed one-invoice pair; Ecaz keeps quiet living/unclaimed Duke then a real owner-labelled mandatory Occupy; Moritani keeps skill-first assassination and one private Mentat replacement. Mixed/E3 pairs, shared Duke, borrowed Smuggler/pair companions, native free returns, captured/foreign training, Basic odd Ecaz, Advanced Harkonnen and other pending rulings stay guarded.',
@@ -4985,7 +4985,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'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 or selected Ix/CHOAM native/base rosters, including Homeworlds, 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 use original38/50 independent-card setup with optional original phase 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.',
'Fresh supported classic, selected Ix/CHOAM or standalone/paired Ecaz/Moritani all14 Skills may retain these cards with original Tech3+ and Advanced Strongholds2+. Current native free allowance still doubles after recorded use; paid Cyborg surcharges and any Tleilaxu recipient remain, and native unlimited allowances are not reduced to seven. Axlotl accrues once and waits for phase end; mandatory normal Banker uses the actual final Bank payer and waits for Mentat. No revival ledger, refund, token owner or spendable premature income is invented.',
'Supported original all14/card rosters retain Tech3+ and Advanced Strongholds2+, including classic/E1/E2 Worlds. Current native free allowance doubles after recorded use; separate World groups re-observe native population. Typed elite caps, unlimited native allowances, paid Cyborg surcharges and any Tleilaxu recipient stay. Axlotl accrues once and waits for phase end; normal Banker uses the accepted final Bank invoice and waits for Mentat, not a net wallet delta after a separate native payout. No ledger, refund, owner or premature spendable income is invented.',
],
related: ['revival', 'homeworlds', 'ecaz-modules', 'expansion-faction-games', 'implementation-checklist'],
checklist: [
@@ -5012,7 +5012,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'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. Original phase modules preserve their own prerequisites and earned custody. 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.',
'Selected Ix/CHOAM Worlds retain original38/50 setup and native stock, optionally with original Tech3+ and Advanced Strongholds2+. 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. Original phase rewards and earned held-card support stay; admitted Stone compositions retain their separate guards.',
'Supported all14 Skills profiles, including original phase modules, keep the same physical fee and score. Trained Suk rescues only actual committed casualties, never reserve fee counters. Earned Arrakeen pays eligible support, not the fee or personal spice. A native Richese marker must leave the full fee after materialization; future Harass cannot fund a refused seal. Fixed Ecaz and variable ally ownership, mandatory disposal and original winner rewards stay separate.',
'Supported all14/card World/phase profiles keep the same physical fee and score. An own-World fee cannot fund the same physical dial/casualty; pre-fee native traits/face remain original. Suk rescues actual committed losses, never fee counters, keeping native saves at that home or using original typed external destinations. Earned Arrakeen never subsidizes a World or reserve fee. A Richese marker must leave three after materialization; future Harass cannot fund a refused seal. Native E3 card Worlds and unrelated guards remain.',
],
related: ['battle-cards', 'battle', 'homeworlds', 'card-recruits', 'card-harass-withdraw', 'card-truthtrance', 'implementation-checklist'],
checklist: [
@@ -5041,7 +5041,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'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.',
'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.',
'Classic or selected Ix/CHOAM native/base rosters, including Homeworlds, 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.',
'Supported all14 Skills with original Tech/Advanced Strongholds keeps the same typed undialed complement and selected-trainer effects. Ix may return Suboids/Cyborgs, then rescue only committed losses. Native E3 keeps fixed/variable owners separate. Earned held support belongs only to its original eligible selected plan; it creates no reserves or fee funding. A capped Richese marker may legally use Harass when Reinforcements is unfunded; unique single-sector returns stay automatic.',
'Supported all14/card World/phase profiles keep actual typed undialed complements and selected-trainer effects. Ix returns Suboids/Cyborgs, then rescues only committed losses before original aftercare/Face Dance. Visitor returns use original native custody; Advanced Emperor normals go to Kaitain and elites to Salusa. Every own World still forbids native Harass. Earned held support is local to its eligible holder-plan, not a World subsidy or fee funding. Capped Richese may use legal Harass when R is unfunded; no token or reserve pool resets.',
],
related: ['battle-cards', 'battle', 'card-recruits', 'homeworlds', 'ecaz-modules', 'implementation-checklist'],
checklist: [
-35
View File
@@ -5,13 +5,8 @@ import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { isPlanetologistBattleSpecialCard } from '../game/leader-skill-combat';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { basicChoamLeaderSkillsProfile } from '../game/leader-skill-profile';
import {
applyAction,
createGame,
initializeLeaderSkillsGameForAudit,
joinGame,
newPlayer,
normalizeAutomaticGame,
viewGame,
type Action,
@@ -83,7 +78,6 @@ void test('all fourteen production skill faces deal through genuine Basic CHOAM
player(game, 'c').leaders.some((leader) => leader.id === 'choam-auditor'),
false,
);
assert.equal(basicChoamLeaderSkillsProfile(game), true);
for (const participant of game.players) {
const view = viewGame(game, participant.id);
assert.equal(view.leaderSkills!.offer, null);
@@ -95,35 +89,6 @@ void test('all fourteen production skill faces deal through genuine Basic CHOAM
});
void test('Advanced CHOAM and Basic Richese retain audit admission without entering the exact normal profile', () => {
for (const variant of ['advanced', 'richese'] as const) {
let game = createGame(
variant === 'advanced' ? 'CHOADVAN' : 'CHORICHE',
newPlayer('c', 'CHOAM', 'choam'),
variant === 'advanced',
['choam'],
);
joinGame(
game,
variant === 'richese'
? newPlayer('r', 'Richese', 'richese')
: newPlayer('a', 'Atreides', 'atreides'),
);
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
for (const participant of game.players)
game = applyAction(game, participant.id, { type: 'ready' });
game = initializeLeaderSkillsGameForAudit(game);
assert.equal(game.setupStage, 'leaderSkills');
assert.equal(basicChoamLeaderSkillsProfile(game), false);
if (variant === 'advanced')
assert.equal(
player(game, 'c').leaders.some(
(leader) => leader.id === 'choam-auditor',
),
true,
);
}
});
void test('CHOAM Planetologist and Sandmaster controls and bots submit legal shared movement actions', () => {
let game = movementPosition(
+38 -5
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeEcazTreacheryGameForAudit, joinGame,
applyAction, createGame, initializeEcazTreacheryGameForAudit, initializeLeaderSkillsGameForAudit, joinGame,
newPlayer, viewGame, type Action, type Game, type Player,
} from '../game/engine';
import { treacheryDeck } from '../game/cards';
@@ -13,11 +13,18 @@ import { nativeShipmentSources } from '../game/homeworld-options';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import { finishHarassPreparation, takeHarassCard } from './fixture-harass-withdraw';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
import { skillOfferRolls } from './fixture-independent-card-modules';
import type { FactionId } from '../game/catalog';
import type { LeaderSkillId } from '../game/leader-skill-cards';
export const HARASS_HOMEWORLD_CARD = 'ecaz-harass-withdraw';
export type HarassHomeworldStep = { actor: string; action: Action };
export type HarassHomeworldOptions = {
advanced?: boolean;
skills?: boolean;
training?: Partial<Record<FactionId, LeaderSkillId>>;
tech?: boolean;
strongholds?: boolean;
slot?: 'weapon' | 'defense';
role?: 'visitor' | 'native';
/** Salusa is an Advanced own-World rejection case, not a second faction. */
@@ -84,7 +91,18 @@ export function actHarassHomeworld(game: Game, step: HarassHomeworldStep, action
/** Never invent a plan, native vote or late-defense permission. */
export function nextHarassHomeworldStep(game: Game): HarassHomeworldStep | null {
if (!game.response && !game.phaseOpening) {
if (game.status === 'setup' && game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0], offer = game.leaderSkills!.offers[actor];
const own = viewGame(game, actor).leaderSkills!;
const skill = offer.cards.find(card => !own.unavailableSkills?.[card]), leader = own.eligibleLeaders[0];
assert.ok(skill && leader, 'Choose an original offered skill and eligible native disc');
return { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: leader.id } };
}
const d = game.decision;
if (d?.kind === 'mentatQuestion' && d.stage === 'name')
return { actor: d.player, action: { type: 'decision', event: d.event, decline: true } };
if (d?.kind === 'leaderSkillVisibility')
return { actor: d.player, action: { type: 'leaderSkillVisibility', event: d.event, hide: true } };
if (d?.kind === 'harassWithdraw') return null;
if (d?.kind === 'homeworldDefense') return { actor: d.player, action: { type: 'decision', event: d.event, use: false } };
if (d?.kind === 'homeworldExplosion') return { actor: d.player, action: { type: 'decision', event: d.event, choice: 0 } };
@@ -105,12 +123,23 @@ export function advanceHarassHomeworld(game: Game, until: (game: Game) => boolea
}
throw Error('Original Harass Homeworld programme did not reach its boundary');
}
function initialize(game: Game): Game {
function initialize(game: Game, options: Pick<HarassHomeworldOptions, 'skills' | 'training'>): Game {
const rolls = options.skills ? skillOfferRolls(game, options.training) : [];
let cursor = 0;
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
crypto.getRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
assert.ok(array); new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255); return array;
assert.ok(array);
if (array instanceof Uint32Array && array.length === 1 && cursor < rolls.length) array[0] = rolls[cursor++];
else new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255);
return array;
};
try { return initializeEcazTreacheryGameForAudit(game); }
try {
if (options.skills) {
game.ecazTreachery = true;
return initializeLeaderSkillsGameForAudit(game);
}
return initializeEcazTreacheryGameForAudit(game);
}
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
@@ -134,11 +163,15 @@ export function createHarassHomeworldSetup(options: HarassHomeworldOptions = {})
!game.discoveryEnabled && !game.discoveries && !game.greatMaker);
if (!game.homeworlds) game = applyAction(game, game.host, { type: 'homeworlds', enabled: true });
assert.deepEqual(game.homeworlds, { custody: null });
if (options.tech !== undefined && !!game.techTokens !== options.tech)
game = applyAction(game, game.host, { type: 'techTokens', enabled: options.tech });
if (options.strongholds !== undefined && !!game.strongholdCards !== options.strongholds)
game = applyAction(game, game.host, { type: 'strongholdCards', enabled: options.strongholds });
const nativeSteps: HarassHomeworldStep[] = [];
for (const p of game.players) if (!p.ready)
game = actHarassHomeworld(game, { actor: p.id, action: { type: 'ready' } }, nativeSteps);
const initial = structuredClone(game);
game = initialize(game);
game = initialize(game, options);
const initialized = structuredClone(game);
game = advanceHarassHomeworld(game, g => g.status === 'playing', nativeSteps);
const owner = game.players.find(p => p.faction === 'emperor')!.id;
+1 -1
View File
@@ -25,7 +25,7 @@ export type IndependentCardModulesOptions = {
};
/** Select only the original undealt fourteen-card lottery, never assignments. */
function skillOfferRolls(game: Game, training: IndependentCardModulesOptions['training']): number[] {
export function skillOfferRolls(game: Game, training: IndependentCardModulesOptions['training']): number[] {
const source = LEADER_SKILL_CARDS.map(card => card.id), selected: LeaderSkillId[] = [];
for (const player of game.players) {
const skill = training?.[player.faction] ?? source.find(card => !selected.includes(card))!;
+18 -6
View File
@@ -7,7 +7,7 @@ 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 { createIndependentCardModulesFixture, type IndependentCardModulesOptions } from './fixture-independent-card-modules';
import type { NativeReserveSelections } from '../game/homeworld-native-reserves';
import {
advanceHarassHomeworld, harassHomeworldClean, nextHarassHomeworldStep,
@@ -21,6 +21,10 @@ export type NativeWorldBattleOptions = {
/** Original unused phase modules selected before native setup. */
tech?: boolean;
strongholds?: boolean;
skills?: boolean;
training?: IndependentCardModulesOptions['training'];
/** Conceal and actually select the owner's originally trained native disc. */
skilled?: boolean;
route?: 'ix-visitor' | 'ix-native' | 'choam-native';
/** Actual first-turn export, not a reserve assignment. */
exportNormal?: number;
@@ -101,6 +105,7 @@ export function createNativeWorldBattleFixture(options: NativeWorldBattleOptions
const advanced = options.advanced ?? false;
const setup = createIndependentCardModulesFixture({ advanced, homeworlds: true,
tech: options.tech ?? false, strongholds: options.strongholds ?? false,
skills: options.skills, training: options.training,
factions: route === 'choam-native' ? ['choam', 'guild', 'tleilaxu', 'ixians'] : ['ixians', 'tleilaxu', 'guild'],
expansions: route === 'choam-native' ? ['ix', 'choam'] : ['ix'] });
let game = setup.game;
@@ -147,11 +152,15 @@ export function createNativeWorldBattleFixture(options: NativeWorldBattleOptions
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))
const living = (actor: string) => nativeWorldBattlePlayer(game, actor).leaders.filter(l => !l.dead && !l.usedAt && !isAuditorLeader(l) &&
(!game.leaderSkills?.assignments.some(a => a.owner === actor && a.leader === l.id) || actor === owner && options.skilled))
.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 trainer = options.skilled ? game.leaderSkills?.assignments.find(a => a.owner === owner) : null;
assert.ok(!options.skilled || trainer, 'Select Skills before native setup to use its real trainer');
const ownLeader = trainer ? living(owner).find(l => l.id === trainer.leader)!
: 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) {
@@ -162,7 +171,9 @@ export function createNativeWorldBattleFixture(options: NativeWorldBattleOptions
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);
game = advanceNativeWorldBattle(game, g => !!g.battle && harassHomeworldClean(g) &&
!g.battle.preparation && g.battle.preLeader?.closed !== false, nativeSteps,
{ skilled: options.skilled ? [owner] : [] });
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);
@@ -185,7 +196,7 @@ export function createNativeWorldBattleFixture(options: NativeWorldBattleOptions
* silently decline the native Face Dance, Auditor or Cyborg exchange. */
export function advanceNativeWorldBattle(game: Game, until: (g: Game) => boolean,
steps: NativeWorldBattleStep[] = [], options: { returns?: Record<string, HomeworldForces>; callers?: string[];
casualties?: HomeworldForces } = {}): Game {
casualties?: HomeworldForces; skilled?: readonly string[] } = {}): 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,
@@ -201,6 +212,7 @@ export function advanceNativeWorldBattle(game: Game, until: (g: Game) => boolean
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 === 'leaderSkillVisibility') step.action.hide = options.skilled?.includes(step.actor) ?? false;
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);
}
+14 -7
View File
@@ -11,7 +11,10 @@ import {
type RicheseReinforcementsStep,
} from './fixture-richese-reinforcements';
export type NativeWorldMarkerOptions = Omit<RicheseReinforcementsOptions, 'initial' | 'seatIds'>;
export type NativeWorldMarkerOptions = Omit<RicheseReinforcementsOptions, 'initial' | 'seatIds'> & {
tech?: boolean;
strongholds?: boolean;
};
export type NativeWorldMarkerFixture = RicheseReinforcementsFixture & {
/** Ready but undealt: the original initializer seam for controls/policies. */
readyLobby: Game;
@@ -26,10 +29,12 @@ export type NativeWorldMarkerFixture = RicheseReinforcementsFixture & {
};
};
/** 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.
/** Genuine selected-family World programme, with optional all14 training and
* original Tech/Advanced Strongholds selected before the original initializer.
* Only that 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(
@@ -39,8 +44,10 @@ export function createNativeWorldMarkerFixture(
const selected = createIndependentCardModulesFixture({
advanced: options.advanced ?? false,
homeworlds: true,
tech: false,
strongholds: false,
tech: options.tech ?? false,
strongholds: options.strongholds ?? false,
skills: options.skills,
training: options.training,
expansions: family === 'choam' ? ['choam'] : ['ix', 'choam'],
factions: family === 'choam' ? ['richese', 'guild', 'atreides'] : ['richese', 'ixians', 'atreides'],
});
+25 -10
View File
@@ -4,7 +4,7 @@ import { combatArmy } from '../game/combat-location';
import { nativeShipmentSources } from '../game/homeworld-options';
import type { NativeReserveSelections } from '../game/homeworld-native-reserves';
import type { HomeworldForces } from '../game/homeworld-custody';
import { createIndependentCardModulesFixture } from './fixture-independent-card-modules';
import { createIndependentCardModulesFixture, type IndependentCardModulesOptions } from './fixture-independent-card-modules';
import { actWorldPhaseBattle, advanceWorldPhaseBattle } from './fixture-world-phase-battle';
import { harassHomeworldClean, nextHarassHomeworldStep } from './fixture-harass-homeworld';
import { takeHarassCard } from './fixture-harass-withdraw';
@@ -18,6 +18,10 @@ export type NativeWorldPhaseBattleOptions = {
advanced?: boolean;
tech?: boolean;
strongholds?: boolean;
skills?: boolean;
training?: IndependentCardModulesOptions['training'];
skilled?: boolean;
harass?: boolean;
/** Actual Cyborg support, never support for Reinforcements' score-only +2. */
support?: 0 | 1 | 3;
};
@@ -57,6 +61,7 @@ export function createNativeWorldPhaseBattleFixture(
const advanced = options.advanced ?? true;
const setup = createIndependentCardModulesFixture({ advanced, homeworlds: true,
tech: options.tech ?? true, strongholds: options.strongholds ?? advanced,
skills: options.skills, training: options.training,
factions: ['ixians', 'tleilaxu', 'guild'], expansions: ['ix'] });
let game = setup.game;
const afterSetup = structuredClone(game), nativeSteps = [...setup.steps];
@@ -108,17 +113,23 @@ export function createNativeWorldPhaseBattleFixture(
reach(g => g.phase === 6 && !!g.active && harassHomeworldClean(g));
for (const player of game.players) game.deck.push(...player.hand.splice(0));
takeHarassCard(game, owner, REINFORCEMENTS_CARD);
takeHarassCard(game, owner, HARASS_WITHDRAW_CARD);
staging.push('Return all original hands, including Ix setup stock, to undealt stock; conserve original R/H into Ix’s two real slots. This is not a purchase.');
if (options.harass ?? true) takeHarassCard(game, owner, HARASS_WITHDRAW_CARD);
staging.push('Return all original hands, including Ix setup stock, to undealt stock; conserve original R and optional H into Ix’s real slots. This is not a purchase.');
const beforeBattle = structuredClone(game);
const choice = viewGame(game, game.active!).battleChoices.find(c => c.territory === 'arrakeen'); assert.ok(choice);
const battleStep: NativeWorldBattleStep = { actor: choice.chooser, action: { type: 'chooseBattle', territory: 'arrakeen',
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } };
act(battleStep);
reach(g => !!g.battle && harassHomeworldClean(g) && !g.battle.preparation && g.battle.preLeader?.closed !== false);
const living = (actor: string) => nativeWorldBattlePlayer(game, actor).leaders.filter(l => !l.dead && !l.usedAt)
game = advanceNativeWorldBattle(game, g => !!g.battle && harassHomeworldClean(g) &&
!g.battle.preparation && g.battle.preLeader?.closed !== false, nativeSteps,
{ skilled: options.skilled ? [owner] : [] });
const living = (actor: string) => nativeWorldBattlePlayer(game, actor).leaders.filter(l => !l.dead && !l.usedAt &&
(!game.leaderSkills?.assignments.some(a => a.owner === actor && a.leader === l.id) || actor === owner && options.skilled))
.sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id));
const ownLeader = living(owner).at(-1)!, otherLeader = living(opponent)[0];
const trainer = options.skilled ? game.leaderSkills?.assignments.find(a => a.owner === owner) : null;
assert.ok(!options.skilled || trainer, 'Select Skills before native setup to use its real trainer');
const ownLeader = trainer ? living(owner).find(l => l.id === trainer.leader)! : living(owner).at(-1)!;
const otherLeader = living(opponent)[0];
assert.ok(ownLeader && otherLeader);
conserveDancer(game, ownLeader.id, staging);
const support = advanced ? options.support ?? 3 : 0;
@@ -127,7 +138,8 @@ export function createNativeWorldPhaseBattleFixture(
// they dial three; each actually supported Cyborg adds one, not another token.
const planActions: NativeWorldPhaseBattleFixture['planActions'] = [
{ actor: owner, action: { type: 'battlePlan', dial: advanced ? 3 + support : 6, support,
leader: ownLeader.id, weapon: REINFORCEMENTS_CARD, defense: HARASS_WITHDRAW_CARD, reinforcementsSources: sources } },
leader: ownLeader.id, weapon: REINFORCEMENTS_CARD, defense: (options.harass ?? true) ? HARASS_WITHDRAW_CARD : null,
reinforcementsSources: sources } },
{ actor: opponent, action: { type: 'battlePlan', dial: 0, support: 0, leader: otherLeader.id } },
];
const returns = { [location]: { normal: 3, elite: 0 } };
@@ -165,13 +177,16 @@ export function finishNativeWorldPhaseBattle(game: Game, fixture: NativeWorldPha
* Cyborg/Face Dance aftercare; own Ix retains its original H prohibition.
*/
export function createNativeWorldPhaseForeignBattleFixture(options: {
advanced?: boolean; typedAftercare?: boolean;
advanced?: boolean; typedAftercare?: boolean; skills?: boolean;
training?: IndependentCardModulesOptions['training']; skilled?: boolean;
} = {}): NativeWorldBattleFixture {
const advanced = options.advanced ?? true;
return createNativeWorldBattleFixture({ advanced, tech: true, strongholds: advanced,
skills: options.skills, training: options.training, skilled: options.skilled,
...(options.typedAftercare ? { exportNormal: 9, exportElite: 1, ownerDial: advanced ? 1 : 2,
harass: false, outcome: 'win' as const, faceDancer: true } : {}) });
}
export function createNativeWorldPhaseOwnBattleFixture(advanced = true): NativeWorldBattleFixture {
return createNativeWorldBattleFixture({ advanced, tech: true, strongholds: advanced, route: 'ix-native' });
export function createNativeWorldPhaseOwnBattleFixture(advanced = true,
options: Pick<NativeWorldPhaseBattleOptions, 'skills' | 'training' | 'skilled'> = {}): NativeWorldBattleFixture {
return createNativeWorldBattleFixture({ advanced, tech: true, strongholds: advanced, route: 'ix-native', ...options });
}
+10 -2
View File
@@ -7,6 +7,7 @@ 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 type { IndependentCardModulesOptions } from './fixture-independent-card-modules';
import { createIndependentCardModulesFixture } from './fixture-independent-card-modules';
import { nextHarassHomeworldStep } from './fixture-harass-homeworld';
import { takeHarassCard } from './fixture-harass-withdraw';
@@ -90,13 +91,17 @@ export function createNativeWorldRecruitsFixture(options: {
imperialCasualties?: boolean;
tech?: boolean;
strongholds?: boolean;
/** Default off; preferred cards are genuine original offers, not assignments. */
skills?: boolean;
training?: IndependentCardModulesOptions['training'];
} = {}): NativeWorldRecruitsFixture {
const native = options.faction ?? 'ixians';
const advanced = options.advanced ?? false;
const tech = options.tech ?? false, strongholds = options.strongholds ?? false;
assert.ok(!strongholds || advanced, 'Original Stronghold Cards require Advanced');
const setup = createIndependentCardModulesFixture({ advanced,
homeworlds: true, tech, strongholds, factions: ['emperor', native, 'guild'],
homeworlds: true, tech, strongholds, skills: options.skills, training: options.training,
factions: ['emperor', native, 'guild'],
expansions: native === 'ixians' || native === 'tleilaxu' ? ['ix'] : ['choam'] });
let game = setup.game;
const afterSetup = structuredClone(game), steps = [...setup.steps];
@@ -109,7 +114,8 @@ export function createNativeWorldRecruitsFixture(options: {
assert.ok(Object.values(game.strongholdCards.owners).every(owner => owner === null),
'Only original end-Mentat may earn Stronghold Cards');
}
for (const [name, value] of [['Nexus Cards', game.nexusCards], ['Leader Skills', game.leaderSkills],
assert.equal(!!game.leaderSkills, options.skills ?? false);
for (const [name, value] of [['Nexus Cards', game.nexusCards],
['Discovery', game.discoveryEnabled]] as const)
assert.equal(value, undefined, `${name} must stay outside a native World Recruits programme`);
const visitor = 'emperor', owner = visitor;
@@ -121,6 +127,8 @@ export function createNativeWorldRecruitsFixture(options: {
const staging = ['Return original setup hands to the unopened Treachery deck; no weapon or defense is used.'];
if (tech || strongholds)
staging.push('Select requested unused original Tech/Advanced Strongholds before readiness; native first Storm assigns tokens and actual end-Mentat earns cards. No owner or income receipt is assigned.');
if (options.skills)
staging.push('Select the original all14 Skills lottery before initialization and assign actual offered cards; no live Skill or World state is replaced.');
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);
+40 -8
View File
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeEcazTreacheryGameForAudit, joinGame,
newPlayer, viewGame, type Action, type Game,
applyAction, createGame, initializeEcazTreacheryGameForAudit, initializeLeaderSkillsGameForAudit,
joinGame, newPlayer, viewGame, type Action, type Game,
} from '../game/engine';
import { treacheryDeck } from '../game/cards';
import { ecazTreacheryCards } from '../game/ecaz-cards';
@@ -9,7 +9,9 @@ import { homeworldGameIntegrity } from '../game/homeworld-game';
import { nativeShipmentSources } from '../game/homeworld-options';
import { RECRUITS_CARD_ID } from '../game/recruits';
import { nextHarassHomeworldStep } from './fixture-harass-homeworld';
import { finishHarassPreparation, takeHarassCard } from './fixture-harass-withdraw';
import { takeHarassCard } from './fixture-harass-withdraw';
import { skillOfferRolls,
type IndependentCardModulesOptions } from './fixture-independent-card-modules';
export type RecruitsHomeworldStep = { actor: string; action: Action };
export type RecruitsHomeworldFixture = {
@@ -57,6 +59,8 @@ export function createRecruitsHomeworldFixture(options: {
advanced?: boolean;
seatIds?: readonly [string, string, string, string];
initial?: Game;
skills?: boolean;
training?: IndependentCardModulesOptions['training'];
} = {}): RecruitsHomeworldFixture {
const ids = options.seatIds ?? ['emperor', 'fremen', 'atreides', 'guild'];
let game = options.initial ? structuredClone(options.initial) :
@@ -72,18 +76,44 @@ export function createRecruitsHomeworldFixture(options: {
};
for (const p of game.players) if (!p.ready) act(p.id, { type: 'ready' });
const initial = structuredClone(game);
const rolls = options.skills ? skillOfferRolls(game, options.training) : [];
let cursor = 0;
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
crypto.getRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
assert.ok(array); new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255); return array;
assert.ok(array);
if (array instanceof Uint32Array && array.length === 1 && cursor < rolls.length)
array[0] = rolls[cursor++];
else new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255);
return array;
};
try { game = initializeEcazTreacheryGameForAudit(game); }
try {
if (options.skills) {
game.ecazTreachery = true;
game = initializeLeaderSkillsGameForAudit(game);
} else game = initializeEcazTreacheryGameForAudit(game);
}
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
game = advanceRecruitsHomeworld(game, g => g.status === 'playing', nativeSteps);
for (let count = 0; game.status === 'setup' && count < 300; count++) {
if (game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0], offer = game.leaderSkills!.offers[actor];
const own = viewGame(game, actor).leaderSkills!, player = recruitsHomeworldPlayer(game, actor);
const skill = options.training?.[player.faction] ?? offer.cards.find(card => !own.unavailableSkills?.[card]);
const leader = own.eligibleLeaders[0];
assert.ok(skill && offer.cards.includes(skill) && !own.unavailableSkills?.[skill] && leader);
act(actor, { type: 'leaderSkill', event: offer.event, skill, leader: leader.id });
} else {
const step = nextHarassHomeworldStep(game); assert.ok(step);
act(step.actor, step.action);
}
}
assert.equal(game.status, 'playing');
const afterSetup = structuredClone(game);
const staging = ['Return initial real hands to the unopened deck; no battle weapons, fake counters or wallet assignments.'];
if (options.skills)
staging.push('Select original all14 Skills before initialization of the supplied undealt lobby; assign actual offers without replacing seat identities, Worlds or original phase modules.');
for (const p of game.players) { game.deck.push(...p.hand); p.hand = []; }
// Original non-worm cards keep the first Blow away from an unrelated riding
// decision. This is not a fabricated Spice Card or phase assignment.
@@ -119,7 +149,8 @@ export function createRecruitsHomeworldFixture(options: {
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker });
}
if (game.battle && !game.battle.revealed) {
game = finishHarassPreparation(game);
game = advanceRecruitsHomeworld(game, g => recruitsHomeworldClean(g) &&
!g.battle?.preparation && g.battle?.preLeader?.closed !== false, nativeSteps);
const battle = game.battle!;
for (const actor of [battle.attacker, battle.defender]) {
if (game.battle!.plans[actor]) continue;
@@ -127,7 +158,8 @@ export function createRecruitsHomeworldFixture(options: {
const leaders = p.leaders.filter(l => !l.dead && !l.usedAt).sort((a, b) => a.strength - b.strength);
const leader = actor === ids[0] || actor === ids[3] ? leaders.at(-1) : leaders[0]; assert.ok(leader);
act(actor, { type: 'battlePlan', leader: leader.id, dial: 0, support: 0 });
game = finishHarassPreparation(game);
game = advanceRecruitsHomeworld(game, g => recruitsHomeworldClean(g) &&
!g.battle?.preparation && g.battle?.preLeader?.closed !== false, nativeSteps);
}
} else {
const step = nextHarassHomeworldStep(game); assert.ok(step); act(step.actor, step.action);
+20 -3
View File
@@ -7,6 +7,7 @@ import { nativeShipmentSources } from '../game/homeworld-options';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import { takeHarassCard } from './fixture-harass-withdraw';
import type { IndependentCardModulesOptions } from './fixture-independent-card-modules';
import {
actHarassHomeworld, advanceHarassHomeworld, assertHarassHomeworldCustody,
createHarassHomeworldSetup, harassHomeworldClean, harassHomeworldPlayer,
@@ -21,6 +22,12 @@ export type ReinforcementsHomeworldStep = HarassHomeworldStep;
export type ReinforcementsHomeworldOptions = {
advanced?: boolean;
initial?: Game;
skills?: boolean;
training?: IndependentCardModulesOptions['training'];
tech?: boolean;
strongholds?: boolean;
/** Use the genuinely assigned trainer after the original visibility choice. */
skilled?: boolean;
seatIds?: [string, string, string];
role?: 'visitor' | 'native';
secondary?: boolean;
@@ -148,9 +155,13 @@ export function createReinforcementsHomeworldFixture(
takeHarassCard(game, setup.owner, laser); takeHarassCard(game, setup.opponent, shield);
}
staging.push('Return original hands to undealt stock, then conserve the physical Reinforcements and (explosion only) Lasgun/Shield into their owners’ hands; no card is manufactured.');
const living = (actor: string) => harassHomeworldPlayer(game, actor).leaders.filter(l => !l.dead && !l.usedAt)
const living = (actor: string) => harassHomeworldPlayer(game, actor).leaders.filter(l => !l.dead && !l.usedAt &&
(!game.leaderSkills?.assignments.some(a => a.owner === actor && a.leader === l.id) || actor === setup.owner && options.skilled))
.sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id));
const ownLeader = outcome === 'win' ? living(setup.owner).at(-1)! : living(setup.owner)[0];
const trainer = options.skilled ? game.leaderSkills?.assignments.find(a => a.owner === setup.owner) : null;
assert.ok(!options.skilled || trainer, 'Select Skills before native setup to use its real trainer');
const ownLeader = trainer ? living(setup.owner).find(l => l.id === trainer.leader)!
: outcome === 'win' ? living(setup.owner).at(-1)! : living(setup.owner)[0];
const otherLeader = outcome === 'win' ? living(setup.opponent)[0] : living(setup.opponent).at(-1)!;
if (outcome === 'traitor') conserveTraitor(game, native,
native === setup.owner ? otherLeader.id : ownLeader.id, staging);
@@ -161,7 +172,13 @@ export function createReinforcementsHomeworldFixture(
action: { type: 'chooseBattle', territory: location,
target: battleChoice.attacker === battleChoice.chooser ? battleChoice.defender : battleChoice.attacker } };
act(battleStep);
reach(g => !!g.battle && harassHomeworldClean(g) && !g.battle.preparation && g.battle.preLeader?.closed !== false);
for (let n = 0; n < 300; n++) {
if (game.battle && harassHomeworldClean(game) && !game.battle.preparation && game.battle.preLeader?.closed !== false) break;
const step = nextHarassHomeworldStep(game); assert.ok(step, 'Complete the original battle preparation');
if (step.action.type === 'leaderSkillVisibility') step.action.hide = step.actor === setup.owner && !!options.skilled;
act(step);
}
assert.ok(game.battle && harassHomeworldClean(game) && !game.battle.preparation && game.battle.preLeader?.closed !== false);
assert.equal(Object.keys(game.battle!.plans).length, 0);
const sources = structuredClone(options.sources ?? reinforcementsHomeworldSources());
const dial = options.ownerDial ?? (role === 'visitor' || outcome === 'win' ? 2 : 0);
+59 -14
View File
@@ -1,12 +1,15 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeEcazTreacheryGameForAudit, joinGame, newPlayer,
applyAction, createGame, initializeEcazTreacheryGameForAudit, initializeLeaderSkillsGameForAudit,
joinGame, newPlayer,
viewGame, type Action, type Game,
} from '../game/engine';
import { botActions } from '../game/bots';
import type { Card } from '../game/cards';
import type { FactionId } from '../game/catalog';
import type { NoFieldValue } from '../game/richese-no-field';
import type { LeaderSkillId } from '../game/leader-skill-cards';
import { skillOfferRolls } from './fixture-independent-card-modules';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
export const RICHESE_REINFORCEMENTS = 'ecaz-reinforcements';
@@ -40,6 +43,13 @@ export const RICHESE_ARMIES = [
export type RicheseReinforcementsOptions = {
advanced?: boolean;
family?: RicheseFamily;
/** Original all-fourteen setup, off unless selected before initialization. */
skills?: boolean;
training?: Partial<Record<FactionId, LeaderSkillId>>;
/** The Richese owner actually moves its trained disc behind the shield. */
skilled?: boolean;
/** Separate own-spice commitment, never reserve-fee or support funding. */
bankerSpice?: number;
/**
* Natural multi-turn route. 'tuning' is the deterministic Basic route: two
* real No-Field reveals plus one real tuning shipment reach a low reserve
@@ -103,7 +113,9 @@ export type RicheseReinforcementsFixture = {
plain: RicheseReinforcementsStep;
opponent: RicheseReinforcementsStep;
};
/** Every real native action the programme took, in order. */
/** Original dealt setup choices, including ordinary-disc training when selected. */
setupSteps: RicheseReinforcementsStep[];
/** Every real native action after setup, in order. */
nativeSteps: RicheseReinforcementsStep[];
/** Every conserved card exchange the programme staged, in order. */
staging: string[];
@@ -130,6 +142,9 @@ export function nextRicheseReinforcementsStep(
game: Game,
stoneMode: 'kill' | 'ignore' = 'ignore',
): RicheseReinforcementsStep | null {
const d = !game.response && !game.phaseOpening ? game.decision : null;
if (d?.kind === 'mentatQuestion' && d.stage === 'name')
return { actor: d.player, action: { type: 'decision', event: d.event, decline: true } };
const step = nextRicheseStrongholdsNativeStep(game);
if (step && game.decision?.kind === 'stoneBurner' &&
step.action.type === 'decision' && step.action.mode !== undefined)
@@ -138,14 +153,23 @@ export function nextRicheseReinforcementsStep(
}
/** Determinism is scoped to the original native initializer only. */
function initialize(game: Game): Game {
function initialize(game: Game, options: RicheseReinforcementsOptions): Game {
const rolls = options.skills ? skillOfferRolls(game, options.training) : [];
let cursor = 0;
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
crypto.getRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
assert.ok(array);
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255);
if (array instanceof Uint32Array && array.length === 1 && cursor < rolls.length) array[0] = rolls[cursor++];
else new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255);
return array;
};
try { return initializeEcazTreacheryGameForAudit(game); }
try {
if (options.skills) {
game.ecazTreachery = true;
return initializeLeaderSkillsGameForAudit(game);
}
return initializeEcazTreacheryGameForAudit(game);
}
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
@@ -204,8 +228,10 @@ function transferCard(game: Game, actor: string, cardId: string, staging: string
}
function availableLeaders(game: Game, actor: string, battleTerritory: string) {
const assignment = game.leaderSkills?.assignments.find(skill => skill.owner === actor);
return richeseReinforcementsPlayer(game, actor).leaders
.filter(leader => !leader.dead && (!leader.usedAt || leader.usedAt === battleTerritory))
.filter(leader => !leader.dead && (!leader.usedAt || leader.usedAt === battleTerritory) &&
(leader.id !== assignment?.leader || game.battle?.leaderSkillHidden?.[actor] === true))
.sort((a, b) => b.strength - a.strength || a.id.localeCompare(b.id));
}
@@ -232,6 +258,8 @@ export function createRicheseReinforcementsFixture(
assert.deepEqual([...game.expansions].sort(), family === 'choam' ? ['choam'] : ['choam', 'ix']);
assert.ok(game.players.some(player => player.faction === 'richese'));
if (game.status === 'setup') assert.equal(game.ecazTreachery, true);
if (game.status === 'setup' && options.skills)
assert.ok(game.leaderSkills, 'Skills must be selected in the original initializer, never retrofitted into setup');
} else {
const roster: FactionId[] = family === 'choam'
? ['richese', 'guild', 'atreides'] : ['richese', 'ixians', 'atreides'];
@@ -243,18 +271,30 @@ export function createRicheseReinforcementsFixture(
}
if (game.status === 'lobby') {
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
game = initialize(game);
game = initialize(game, options);
}
const initial = structuredClone(game);
const setupSteps: RicheseReinforcementsStep[] = [];
for (let count = 0; game.status === 'setup' && count < 300; count++) {
let next: RicheseReinforcementsStep | undefined;
for (const player of game.players) {
const view = viewGame(game, player.id);
view.players.find(p => p.id === player.id)!.bot = 'Easy';
const action = botActions(view)[0];
if (action) { next = { actor: player.id, action }; break; }
if (game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0], offer = game.leaderSkills!.offers[actor];
const view = viewGame(game, actor).leaderSkills!, player = richeseReinforcementsPlayer(game, actor);
const skill = options.training?.[player.faction] ?? offer.cards.find(card => !view.unavailableSkills?.[card]);
const leader = player.leaders.filter(disc => view.eligibleLeaders.some(eligible => eligible.id === disc.id))
.sort((a, b) => b.strength - a.strength || a.id.localeCompare(b.id))[0];
assert.ok(skill && offer.cards.includes(skill) && !view.unavailableSkills?.[skill] && leader);
next = { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: leader.id } };
} else {
for (const player of game.players) {
const view = viewGame(game, player.id);
view.players.find(p => p.id === player.id)!.bot = 'Easy';
const action = botActions(view)[0];
if (action) { next = { actor: player.id, action }; break; }
}
}
assert.ok(next, `Original independent-card setup stalled at ${game.setupStage}/${game.decision?.kind}`);
setupSteps.push(structuredClone(next));
game = applyAction(game, next.actor, next.action);
}
assert.equal(game.status, 'playing');
@@ -387,7 +427,11 @@ export function createRicheseReinforcementsFixture(
assert.ok(beforeShipment && afterShipment && shipmentStep && beforeBattle && battleStep &&
reservesBefore && army, 'The timeline must record its final shipment, army and battle admission');
for (let count = 0; count < 120; count++) {
const step = nextRicheseReinforcementsStep(game);
const visibility = game.decision?.kind === 'leaderSkillVisibility' && !game.response && !game.phaseOpening
? game.decision : null;
const step = visibility ? { actor: visibility.player, action: { type: 'leaderSkillVisibility',
event: visibility.event, hide: visibility.player === richese && !!options.skilled } } satisfies RicheseReinforcementsStep
: nextRicheseReinforcementsStep(game);
if (!step) break;
act(step);
}
@@ -429,6 +473,7 @@ export function createRicheseReinforcementsFixture(
RicheseReinforcementsStep => ({
actor: richese,
action: { type: 'battlePlan', dial: ownDial, support: advanced ? ownDial : 0,
...(options.bankerSpice ? { bankerSpice: options.bankerSpice } : {}),
leader: ownLeader.id, weapon: machineSlot(weapon), defense: machineSlot(defense) },
});
const plans = {
@@ -443,7 +488,7 @@ export function createRicheseReinforcementsFixture(
initial, afterSetup, beforeShipment, shipmentStep, afterShipment, beforeBattle, battleStep,
game, richese, opponent, observer, family, advanced, route, location, city,
token: structuredClone(tokenOf(marker)), materializes, tuning, reservesBefore,
machines, hand, plans, nativeSteps, staging,
machines, hand, plans, setupSteps, nativeSteps, staging,
};
}
+218
View File
@@ -0,0 +1,218 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, joinGame, nativeHandLimit, newPlayer, viewGame,
type Action, type Game } from '../game/engine';
import type { FactionId } from '../game/catalog';
import { treacheryDeck, type Card } from '../game/cards';
import type { LeaderSkillId } from '../game/leader-skill-cards';
import { validateLeaderSkills } from '../game/leader-skills';
import { richeseCards } from '../game/richese-cards';
import { traitorDeck } from '../game/traitors';
import { homeworldGameIntegrity } from '../game/homeworld-game';
import { nextChoamIndependentSkillsStep } from './fixture-choam-independent-skills';
import { singleNexusE1Clean, singleNexusE1Player, shipSingleNexusE1,
openSingleNexusE1Battle, revealSingleNexusE1Plans, quoteSingleNexusE1Battle } from './fixture-single-nexus-e1';
import { skillOfferRolls } from './fixture-independent-card-modules';
export type SelectedDeckSkillsStep = { actor: string; action: Action };
export type SelectedDeckSkillsOptions = {
advanced?: boolean;
homeworlds?: boolean;
factions?: readonly FactionId[];
expansions?: Game['expansions'];
/** The first seat's confirmed module technique; later seats share the rest. */
skill?: LeaderSkillId;
/** Play conserved original poison weapons: only the attacker's, or both. */
poison?: 'attacker' | 'both';
};
export const selectedDeckSkillsPlayer = singleNexusE1Player;
export const selectedDeckSkillsClean = singleNexusE1Clean;
export const selectedDeckSkillsQuote = quoteSingleNexusE1Battle;
/** Ordinary techniques that need no card of their own to change a battle. */
const OTHER_SKILLS: readonly LeaderSkillId[] = ['killer-medic', 'swordmaster-of-ginaz',
'prana-bindu-adept', 'sandmaster', 'warmaster'];
/** Entropy selection is confined to the original setup lottery. */
function originalLottery<T>(draws: readonly number[], run: () => T): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); let cursor = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1) array[0] = draws[cursor++] ?? 0xffffffff;
else if (array) new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255);
return array;
};
try { return run(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** Move one conserved physical face out of the real remaining deck. No face is
* ever minted, purchased or replaced, and the printed hand limit still binds. */
function conserveCard(game: Game, actor: string, matches: (card: Card) => boolean,
staging: string[]): Card {
const player = selectedDeckSkillsPlayer(game, actor);
const index = game.deck.findIndex(matches);
assert.ok(index >= 0, 'The conserved original face must still wait in the real deck');
while (player.hand.length >= nativeHandLimit(player)) {
const at = player.hand.findIndex(card => !card.id.startsWith('richese-'));
assert.ok(at >= 0, `${actor}'s conserved cache faces leave no deckable hand room`);
const [card] = player.hand.splice(at, 1);
game.deck.push(card);
staging.push(`Conserved ${card.id}: ${actor}'s hand → remaining deck to keep the printed hand limit`);
}
const [card] = game.deck.splice(index, 1);
player.hand.push(card);
staging.push(`Conserved ${card.id}: remaining deck → ${actor}'s hand (no purchase, payment or replacement)`);
return card;
}
/** The ordinary lobby controls a real host uses: select the module, ready every
* seat, start, then confirm the two private offers. No development initializer,
* no expansion seat beyond the selected decks and no independent card module. */
export function createSelectedDeckSkillsSetup(options: SelectedDeckSkillsOptions = {}) {
const factions = options.factions ?? ['ixians', 'richese', 'guild'];
assert.ok(factions.length >= 2 && new Set(factions).size === factions.length);
let game = createGame('SELECTEDDECKSKILLS', newPlayer(factions[0], factions[0], factions[0]),
options.advanced ?? false, options.expansions ?? ['ix', 'choam']);
for (const faction of factions.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
const actions: SelectedDeckSkillsStep[] = [], staging: string[] = [];
const act = (actor: string, action: Action) => {
actions.push({ actor, action }); game = applyAction(game, actor, action);
};
const initial = structuredClone(game);
if (options.homeworlds) act(game.host, { type: 'homeworlds', enabled: true });
act(game.host, { type: 'moduleOption', module: 'leaderSkills', enabled: true });
for (const player of game.players) act(player.id, { type: 'ready' });
const owner = game.players[0].id;
const chosen: LeaderSkillId[] = [options.skill ?? 'mentat'];
while (chosen.length < game.players.length)
chosen.push(OTHER_SKILLS.find(skill => !chosen.includes(skill))!);
const training = Object.fromEntries(game.players.map((player, index) => [player.faction, chosen[index]]));
game = originalLottery(skillOfferRolls(game, training), () => {
const action: Action = game.advanced ? { type: 'start', advancedPreview: true } : { type: 'start' };
actions.push({ actor: game.host, action }); return applyAction(game, game.host, action);
});
const started = structuredClone(game);
let ixOffer: { before: Game; step: SelectedDeckSkillsStep; after: Game } | null = null;
if (game.decision?.kind === 'ixSetup') {
const actor = game.decision.player, before = structuredClone(game);
const card = viewGame(game, actor).ixTechnology!.setup![0]; assert.ok(card);
const step: SelectedDeckSkillsStep = { actor, action: { type: 'decision', card: card.id } };
act(actor, step.action); ixOffer = { before, step, after: structuredClone(game) };
staging.push('The Ix seat drafted its ordinary Treachery Card before any module technique was confirmed.');
}
const offered = structuredClone(game), trainers: Record<string, string> = {};
for (let count = 0; game.status === 'setup' && count < 300; count++) {
if (game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0], own = viewGame(game, actor).leaderSkills!;
const skill = chosen[game.players.findIndex(player => player.id === actor)];
assert.ok(own.offer!.cards.includes(skill) && !own.unavailableSkills?.[skill]);
const eligible = new Set(own.eligibleLeaders.map(leader => leader.id));
const leader = selectedDeckSkillsPlayer(game, actor).leaders.filter(disc => eligible.has(disc.id))
.sort((a, b) => b.strength - a.strength || a.id.localeCompare(b.id))[0];
assert.ok(leader, `The ${actor} seat still has an eligible original disc`);
trainers[actor] = leader.id;
act(actor, { type: 'leaderSkill', event: own.offer!.event, skill, leader: leader.id });
} else {
const step = nextChoamIndependentSkillsStep(game);
assert.ok(step, `Consume the original ${game.setupStage ?? game.decision?.kind} setup window`);
act(step.actor, step.action);
}
}
assert.equal(game.status, 'playing');
const seats: Partial<Record<FactionId, string>> = {};
for (const player of game.players) seats[player.faction] = player.id;
return { initial, started, offered, ixOffer, afterSetup: structuredClone(game),
owner, seats, trainers, skills: chosen, actions, staging };
}
/** Pauses at unsealed plans and typed physical choices; only real native
* controls advance the chronology. */
export function advanceSelectedDeckSkills(game: Game, until: (g: Game) => boolean,
actions: SelectedDeckSkillsStep[] = []): Game {
for (let count = 0; count < 3000; count++) {
if (until(game)) return game;
assert.equal(game.status, 'playing');
const step = nextChoamIndependentSkillsStep(game);
assert.ok(step, `Supply the real ${game.decision?.kind ?? 'Battle Plan'} at ${game.turn}/${game.phase}`);
actions.push(structuredClone(step)); game = applyAction(game, step.actor, step.action);
}
throw Error('The bare selected-deck chronology did not reach its requested boundary');
}
/** All cards come from the selected ordinary decks plus a seated Richese cache;
* the Auditor is a leader disc and no Traitor face is minted or dropped. */
export function assertSelectedDeckSkillsInventory(game: Game): void {
validateLeaderSkills(game.leaderSkills!, game.players);
if (game.homeworlds) homeworldGameIntegrity(game);
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
...(game.ixSetupCards ?? []), ...(game.ixAuction?.cards ?? []), ...(game.richeseCache ?? []),
...(game.richeseRemoved ?? []),
...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? [])];
const original = [...treacheryDeck(game.expansions),
...(game.players.some(player => player.faction === 'richese') ? richeseCards() : [])];
assert.deepEqual(cards.map(card => card.id).sort(), original.map(card => card.id).sort());
for (const player of game.players) {
assert.ok(player.hand.length <= nativeHandLimit(player));
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, n) => sum + n, 0), 20);
if (player.elites) assert.equal(player.elites.reserves + player.elites.tanks +
Object.values(player.elites.forces).reduce((sum, n) => sum + n, 0),
player.faction === 'ixians' ? 7 : player.faction === 'emperor' ? 5 : 3);
}
if (game.status !== 'lobby' && game.status !== 'setup') assert.deepEqual([
...(game.traitorReserve ?? []), ...game.players.flatMap(player => player.traitors),
...game.players.flatMap(player => (player.faceDancers ?? []).map(card => card.leader)),
].sort(), traitorDeck(game.players, game.expansions.includes('ix')).sort());
}
/** A paid shipment into the untouched Guild army, real posture choices and both
* sealed plans. No hand staging beyond conserved faces and no invented spice. */
export function createSelectedDeckSkillsProgramme(options: SelectedDeckSkillsOptions = {}) {
const setup = createSelectedDeckSkillsSetup(options), actions = setup.actions;
const guild = setup.seats.guild;
assert.ok(guild, 'The programme needs a real Guild seat to attack');
let game = advanceSelectedDeckSkills(setup.afterSetup,
g => g.phase === 5 && g.active === setup.owner && selectedDeckSkillsClean(g), actions);
const attacked = options.poison ? conserveCard(game, setup.owner, card => card.kind === 'poison',
setup.staging) : undefined;
const defended = options.poison === 'both'
? conserveCard(game, guild, card => card.kind === 'poison' && card.id !== attacked!.id, setup.staging)
: undefined;
const shipment = shipSingleNexusE1(game, setup.owner, 'tueks_sietch', 2, 0, actions);
game = openSingleNexusE1Battle(shipment.after, setup.owner, guild, 'tueks_sietch', actions);
const beforePlans = structuredClone(game);
const defender = selectedDeckSkillsPlayer(game, guild).leaders
.filter(disc => !disc.dead && disc.id !== setup.trainers[guild])
.sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id))[0];
assert.ok(defender, 'The Guild seat still holds an untrained original disc');
game = revealSingleNexusE1Plans(game, [
{ actor: setup.owner, action: { type: 'battlePlan', leader: setup.trainers[setup.owner], dial: 0,
...(attacked ? { weapon: attacked.id } : {}) } },
{ actor: guild, action: { type: 'battlePlan', leader: defender.id, dial: 0,
...(defended ? { weapon: defended.id } : {}) } },
], actions);
return { ...setup, guild, shipment, beforePlans, game, defender: defender.id,
weapons: [attacked?.id, defended?.id].filter((id): id is string => !!id) };
}
/** Original poison weapons kill both committed discs for real, then the same
* bare table continues into the actual Revival phase of the next turn. */
export function createSelectedDeckSkillsRevivalCase(options: { advanced?: boolean } = {}) {
const programme = createSelectedDeckSkillsProgramme({ advanced: options.advanced,
factions: ['tleilaxu', 'guild', 'ixians'], skill: 'mentat', poison: 'both' });
const tleilaxu = programme.seats.tleilaxu;
assert.ok(tleilaxu, 'The revival case needs a real Tleilaxu seat');
const actions = programme.actions;
let game = advanceSelectedDeckSkills(programme.game,
g => !g.battle && selectedDeckSkillsClean(g), actions);
const guild = selectedDeckSkillsPlayer(game, programme.guild);
const dead = guild.leaders.find(disc => disc.id === programme.defender)!;
const trainer = selectedDeckSkillsPlayer(game, tleilaxu).leaders
.find(disc => disc.id === programme.trainers[tleilaxu])!;
assert.equal(dead.dead, true, 'The attacker’s conserved poison kills the committed Guild disc');
assert.equal(trainer.dead, true, 'The defender’s conserved poison kills the committed Tleilaxu disc');
assert.ok(guild.leaders.filter(disc => !disc.dead).length > 0);
game = advanceSelectedDeckSkills(game,
g => g.turn === 2 && g.phase === 4 && g.status === 'playing' && selectedDeckSkillsClean(g), actions);
return { ...programme, tleilaxu, actions, game, dead: dead.id, trainer: trainer.id,
advanced: programme.afterSetup.advanced };
}
+22 -8
View File
@@ -11,7 +11,7 @@ import {
harassHomeworldClean, harassHomeworldPlayer, nextHarassHomeworldStep,
type HarassHomeworldStep,
} from './fixture-harass-homeworld';
import { createIndependentCardModulesFixture } from './fixture-independent-card-modules';
import { createIndependentCardModulesFixture, type IndependentCardModulesOptions } from './fixture-independent-card-modules';
import { takeHarassCard } from './fixture-harass-withdraw';
import {
createReinforcementsHomeworldFixture, type ReinforcementsHomeworldFixture,
@@ -23,6 +23,9 @@ export type WorldPhaseBattleOptions = {
advanced?: boolean;
tech?: boolean;
strongholds?: boolean;
skills?: boolean;
training?: IndependentCardModulesOptions['training'];
skilled?: boolean;
support?: number;
};
export type WorldPhaseBattleFixture = {
@@ -68,12 +71,13 @@ export function actWorldPhaseBattle(game: Game, step: WorldPhaseBattleStep,
actions?.push(structuredClone(step)); return firstStormAction(game, step);
}
export function advanceWorldPhaseBattle(game: Game, until: (state: Game) => boolean,
actions?: WorldPhaseBattleStep[]): Game {
actions?: WorldPhaseBattleStep[], skilled: readonly string[] = []): Game {
for (let n = 0; n < 1800; n++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const step = nextHarassHomeworldStep(game);
assert.ok(step, 'Supply the genuine human plan or physical return allocation');
if (step.action.type === 'leaderSkillVisibility') step.action.hide = skilled.includes(step.actor);
game = actWorldPhaseBattle(game, step, actions);
}
throw Error('Original World/phase-module programme did not reach its requested boundary');
@@ -87,10 +91,11 @@ export function advanceWorldPhaseBattle(game: Game, until: (state: Game) => bool
* Existing provisional utility/Stronghold timing is inherited, not a new ruling. */
export function createWorldPhaseBattleFixture(options: WorldPhaseBattleOptions = {}): WorldPhaseBattleFixture {
const advanced = options.advanced ?? options.initial?.advanced ?? true;
const base = options.initial ? createHarassHomeworldSetup({ advanced, initial: options.initial }) : null;
const base = options.initial ? createHarassHomeworldSetup({ ...options, advanced, initial: options.initial }) : null;
const original = base ? { initial: base.initial, game: base.afterSetup, steps: base.nativeSteps }
: createIndependentCardModulesFixture({ advanced, homeworlds: true,
tech: options.tech ?? true, strongholds: options.strongholds ?? advanced,
skills: options.skills, training: options.training,
factions: ['emperor', 'guild', 'atreides'] });
let game = structuredClone(original.game);
const actions = [...original.steps], initial = structuredClone(original.initial);
@@ -153,10 +158,15 @@ export function createWorldPhaseBattleFixture(options: WorldPhaseBattleOptions =
const battleStep: WorldPhaseBattleStep = { actor: choice.chooser, action: { type: 'chooseBattle', territory: 'arrakeen',
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } };
act(battleStep);
reach(g => !!g.battle && harassHomeworldClean(g) && !g.battle.preparation && g.battle.preLeader?.closed !== false);
const living = (actor: string) => harassHomeworldPlayer(game, actor).leaders.filter(l => !l.dead && !l.usedAt)
game = advanceWorldPhaseBattle(game, g => !!g.battle && harassHomeworldClean(g) &&
!g.battle.preparation && g.battle.preLeader?.closed !== false, actions, options.skilled ? [owner] : []);
const living = (actor: string) => harassHomeworldPlayer(game, actor).leaders.filter(l => !l.dead && !l.usedAt &&
(!game.leaderSkills?.assignments.some(a => a.owner === actor && a.leader === l.id) || actor === owner && options.skilled))
.sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id));
const leader = living(owner).at(-1)!, opponentLeader = living(opponent)[0];
const trainer = options.skilled ? game.leaderSkills?.assignments.find(a => a.owner === owner) : null;
assert.ok(!options.skilled || trainer, 'Select Skills before native setup to use its real trainer');
const leader = trainer ? living(owner).find(l => l.id === trainer.leader)! : living(owner).at(-1)!;
const opponentLeader = living(opponent)[0];
const support = advanced ? options.support ?? 3 : 0;
const sources = { 'homeworld:atreides': { normal: 3, elite: 0 } };
const plans: WorldPhaseBattleFixture['plans'] = [
@@ -176,10 +186,12 @@ export function createWorldPhaseBattleFixture(options: WorldPhaseBattleOptions =
/** Exposes exactly the original source-key controls for a genuine own-World
* battle: ten normal Kaitain reserves move to Salusa; Guild pays to invade
* Kaitain. The three fee counters may come from either real normal pool. */
export function createWorldPhaseOwnBattleFixture(options: { initial?: Game; tech?: boolean } = {}): ReinforcementsHomeworldFixture {
export function createWorldPhaseOwnBattleFixture(options: Pick<WorldPhaseBattleOptions,
'initial' | 'tech' | 'skills' | 'training' | 'skilled'> = {}): ReinforcementsHomeworldFixture {
const initial = options.initial ?? createIndependentCardModulesFixture({ advanced: true, homeworlds: true,
tech: options.tech ?? true, strongholds: true, factions: ['emperor', 'guild', 'atreides'] }).initial;
const fixture = createReinforcementsHomeworldFixture({ advanced: true, initial, role: 'native', outcome: 'win',
skills: options.skills, training: options.training, skilled: options.skilled,
nativeTransfer: 10, sources: { 'homeworld:emperor:salusa': { normal: 3, elite: 0 } }, ownerDial: 2, ownerSupport: 2 });
takeHarassCard(fixture.game, fixture.owner, HARASS_WITHDRAW_CARD);
fixture.staging.push('Conserve original Harass & Withdraw from undealt stock into Emperor’s hand solely to exercise the native own-World ban; never seal it in the legal plan.');
@@ -190,10 +202,12 @@ export function createWorldPhaseOwnBattleFixture(options: { initial?: Game; tech
/** Seven original Emperor visitors invade Junction (3N4E), then two normal
* reserves move Kaitain → Salusa. R pays 1N Kaitain + 2N Salusa before H returns
* 2N to Kaitain and 3E to Salusa. Both modules start original and unused. */
export function createWorldPhaseVisitorBattleFixture(options: { initial?: Game } = {}): ReinforcementsHomeworldFixture {
export function createWorldPhaseVisitorBattleFixture(options: Pick<WorldPhaseBattleOptions,
'initial' | 'skills' | 'training' | 'skilled'> = {}): ReinforcementsHomeworldFixture {
const initial = options.initial ?? createIndependentCardModulesFixture({ advanced: true, homeworlds: true,
tech: true, strongholds: true, factions: ['emperor', 'guild', 'atreides'] }).initial;
const fixture = createReinforcementsHomeworldFixture({ advanced: true, initial, role: 'visitor', outcome: 'win',
skills: options.skills, training: options.training, skilled: options.skilled,
slot: 'weapon', nativeTransfer: 2, ownerDial: 2, ownerSupport: 1,
sources: { 'homeworld:emperor': { normal: 1, elite: 0 }, 'homeworld:emperor:salusa': { normal: 2, elite: 0 } } });
takeHarassCard(fixture.game, fixture.owner, HARASS_WITHDRAW_CARD);
+5 -1
View File
@@ -10,11 +10,14 @@ import {
recruitsHomeworldClean, recruitsHomeworldPlayer, type RecruitsHomeworldFixture, type RecruitsHomeworldStep,
} from './fixture-recruits-homeworld';
import { nextHarassHomeworldStep } from './fixture-harass-homeworld';
import type { IndependentCardModulesOptions } from './fixture-independent-card-modules';
export type WorldPhaseRecruitsOptions = {
advanced?: boolean;
strongholds?: boolean;
seatIds?: readonly [string, string, string, string];
skills?: boolean;
training?: IndependentCardModulesOptions['training'];
};
export type WorldPhaseRecruitsFixture = {
@@ -72,7 +75,8 @@ export function createWorldPhaseRecruitsFixture(options: WorldPhaseRecruitsOptio
return array;
};
let source: RecruitsHomeworldFixture;
try { source = createRecruitsHomeworldFixture({ advanced, initial: lobby, seatIds: ids }); }
try { source = createRecruitsHomeworldFixture({ advanced, initial: lobby, seatIds: ids,
skills: options.skills, training: options.training }); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
+44
View File
@@ -158,3 +158,47 @@ void test('Advanced: native CHOAM Reinforcements preserves the original Auditor
game = advanceNativeWorldBattle(game, nativeWorldBattleFinished);
assertNativeWorldBattleInventory(game, f.afterSetup);
});
void test('all14 native Ix Suk saves only the post-fee committed Cyborgs, never the reserve fee or exported garrison', () => {
const f = createNativeWorldBattleFixture({ advanced: true, route: 'ix-native', skills: true, skilled: true,
tech: true, strongholds: true,
training: { ixians: 'suk-graduate', tleilaxu: 'killer-medic', guild: 'sandmaster' } });
const original = structuredClone(f.game);
assert.deepEqual(f.sources, { 'homeworld:ixians': { normal: 1, elite: 2 } });
assert.equal(viewGame(f.game, f.owner).battle!.strongholdEffects[f.owner], null,
'Original unused Strongholds never funds support on its owner’s World');
assert.throws(() => applyAction(f.game, f.owner, { ...f.planActions[0].action, dial: 4.5 }), RuleError);
assert.throws(() => applyAction(f.game, f.owner, { ...f.planActions[0].action, defense: HARASS_WITHDRAW_CARD }), RuleError);
assert.deepEqual(f.game, original);
const revealed = revealNativeWorldBattle(f), forces = viewGame(revealed, f.owner).battle!.ownForces!;
assert.equal(forces.normal, 0); assert.equal(forces.elite, 2);
assert.equal(forces.normalFixedHalf, true); assert.equal(forces.eliteStrength, 2);
assert.equal(forces.freeSupport, false);
assert.deepEqual(viewGame(revealed, f.owner).battle!.traitorVoters, [f.owner]);
let game = advanceNativeWorldBattle(revealed, g => g.decision?.kind === 'sukRescue');
assert.ok(game.decision?.kind === 'sukRescue');
assert.deepEqual(game.pendingSukRescue!.pool, [{ key: f.location, normal: 0, elite: 2 }]);
assert.deepEqual(game.pendingSukRescue!.losses, { normal: 0, elite: 2, paidNormal: 0, paidElite: 0 });
assert.equal(nativeWorldBattlePlayer(game, f.owner).tanks, 3);
assert.equal(nativeWorldBattlePlayer(game, f.owner).elites!.tanks, 2);
assert.ok(game.decision.options.every(o => o.normal === 0 && o.elite <= 2),
'The normal fee and two fee Cyborgs are not Suk casualties');
const choice = game.decision.options.findIndex(o => o.elite === 2 && o.kept?.kind === 'elite');
assert.ok(choice >= 0);
const event = game.decision.event;
game = applyAction(game, f.owner, { type: 'decision', event, choice });
game = advanceNativeWorldBattle(game, nativeWorldBattleFinished);
assert.equal(game.lastBattleContext!.winner, f.owner);
assert.deepEqual(game.lastBattleContext!.sukRescue!.physical, {
player: f.owner, territory: f.location, source: 'native-homeworld', saved: { normal: 0, elite: 2 },
kept: { key: f.location, kind: 'elite' }, destinations: {}, tanks: { normal: 0, elite: 0 },
});
assert.deepEqual(nativeWorldBattlePool(game, f.owner, f.location), { normal: 0, elite: 2 });
assert.deepEqual(combatArmy(game, f.owner, 'homeworld:tleilaxu'), { normal: 9, elite: 0 });
assert.equal(nativeWorldBattlePlayer(game, f.owner).tanks, 3);
assert.equal(nativeWorldBattlePlayer(game, f.owner).elites!.tanks, 2);
assert.equal(nativeWorldBattlePlayer(game, f.owner).battleLosses, 5);
assert.equal(game.discard.filter(c => c.id === REINFORCEMENTS_CARD).length, 1);
assert.ok(nativeWorldBattlePlayer(game, f.owner).hand.some(c => c.id === HARASS_WITHDRAW_CARD));
assertNativeWorldBattleInventory(game, f.afterSetup);
});
+68 -1
View File
@@ -1,6 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, RuleError, viewGame, type Game } from '../game/engine';
import { spiceBankerControlState } from '../components/spice-banker';
import {
createNativeWorldMarkerFixture, nativeWorldMarkerInventory, nativeWorldMarkerPlan,
type NativeWorldMarkerFixture,
@@ -13,7 +14,7 @@ import {
function physicalCards(game: Game) {
return [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
...game.players.flatMap(player => player.hand)].map(card => card.id).sort();
...(game.ixSetupCards ?? []), ...game.players.flatMap(player => player.hand)].map(card => card.id).sort();
}
function primaryPool(game: Game, fixture: NativeWorldMarkerFixture) {
@@ -216,3 +217,69 @@ for (const family of ['choam', 'ix+choam'] as const) {
});
}
void test('all14 mixed native World marker keeps Banker funding private and separate from R fee and H return', () => {
const f = createNativeWorldMarkerFixture({ family: 'ix+choam', advanced: true, marker: 5,
skills: true, skilled: true, training: { richese: 'spice-banker' }, bankerSpice: 1, opponentDial: 8 });
const view = viewGame(f.game, f.richese), owner = richeseReinforcementsPlayer(f.game, f.richese);
const leader = String(f.plans.plain.action.leader);
assert.deepEqual(spiceBankerControlState(view, leader, 1), { eligible: true, maximum: 3 });
assert.deepEqual(spiceBankerControlState(view, leader, 2), { eligible: true, maximum: 2 });
assert.deepEqual(spiceBankerControlState(viewGame(f.game, f.opponent), leader, 1),
{ eligible: false, maximum: 0 }, 'Another viewer cannot commit the native owner’s skill or wallet.');
assert.deepEqual(view.battle!.reinforcements!.noField, { materializes: 5, reservesAfterReveal: 15 });
const plan = nativeWorldMarkerPlan(f, 'both', 'defense');
assert.deepEqual(plan.action.reinforcementsSources,
{ [f.source.homeworld]: { normal: 3, elite: 0 } });
const before = structuredClone(f.game);
assert.throws(() => applyAction(f.game, f.richese,
{ ...plan.action, dial: 2, support: 2, bankerSpice: 3 }), RuleError);
assert.deepEqual(f.game, before, 'Reserve funding and later H returns do not pay an overcommitted own-spice budget.');
const first = prepareRicheseReinforcementsPlan(f.game, plan);
assertStillConcealed(f, first);
assert.equal(richeseReinforcementsPlayer(first, f.richese).spice, owner.spice);
assert.equal(viewGame(first, f.richese).battle!.plans[f.richese].bankerSpice, 1);
assert.deepEqual(viewGame(first, f.observer).battle!.plans, {});
const revealed = sealRicheseReinforcementsPlan(first, f.plans.opponent);
assert.deepEqual(primaryPool(revealed, f), { normal: 15, elite: 0 });
const done = finishRicheseReinforcementsBattle(revealed);
const after = richeseReinforcementsPlayer(done, f.richese);
assert.equal(done.lastBattleContext!.winner, f.opponent);
assert.deepEqual(primaryPool(done, f), { normal: 16, elite: 0 },
'Five materialize, three pay R, then four actual undialed H survivors return to the same primary World.');
assert.equal(after.reserves, 16);
assert.equal(after.tanks, 4);
assert.equal(after.spice, owner.spice - 2, 'One own support plus one Banker commitment, not a reserve-fee subsidy.');
assert.equal(done.phase, 7, 'The original battle continues into collection with no synthetic history.');
assertSpent(done, RICHESE_REINFORCEMENTS);
assertSpent(done, RICHESE_HARASS);
assertConserved(f, done);
});
void test('all14 capped World marker refuses R before a skilled Mentat or future H return, then uses legal H', () => {
const f = createNativeWorldMarkerFixture({ marker: 5, reserves: 3, skills: true, skilled: true,
training: { richese: 'mentat' }, opponentDial: 8 });
const before = structuredClone(f.game), preview = viewGame(f.game, f.richese).battle!;
assert.equal(f.game.battle!.leaderSkillHidden![f.richese], true);
assert.deepEqual(preview.reinforcements!.noField, { materializes: 3, reservesAfterReveal: 0 });
assert.ok(preview.reinforcements!.blocked);
const raw = nativeWorldMarkerPlan(f);
delete raw.action.reinforcementsSources;
for (const plan of [raw, nativeWorldMarkerPlan(f, 'both')]) {
assert.throws(() => applyAction(f.game, f.richese, plan.action), RuleError);
assert.deepEqual(f.game, before, 'The refused first seal spends no skill, cards, token, wallet or native stock.');
}
const first = prepareRicheseReinforcementsPlan(f.game, nativeWorldMarkerPlan(f, 'harass'));
assertStillConcealed(f, first);
const revealed = sealRicheseReinforcementsPlan(first, f.plans.opponent);
assert.deepEqual(primaryPool(revealed, f), { normal: 0, elite: 0 });
assert.equal(richeseReinforcementsPlayer(revealed, f.richese).forces[f.location], 3,
'The actual five-token reveal is capped by its three original native counters.');
const done = finishRicheseReinforcementsBattle(revealed);
assert.equal(done.lastBattleContext!.winner, f.opponent);
assert.deepEqual(primaryPool(done, f), { normal: 1, elite: 0 });
assert.equal(richeseReinforcementsPlayer(done, f.richese).tanks, 2);
assert.ok(richeseReinforcementsPlayer(done, f.richese).hand.some(card => card.id === RICHESE_REINFORCEMENTS));
assertSpent(done, RICHESE_HARASS);
assertConserved(f, done);
});
+69
View File
@@ -148,3 +148,72 @@ void test('Advanced original phase modules: native board-only Cyborg substitutio
'The genuine native typed casualty decision precedes the separate substitution and Face Dance queues');
assertNativeWorldBattleInventory(game, f.afterSetup);
});
void test('all14 earned Arrakeen funds actual Cyborg support while selected Suk rescues board losses before original Face Dance', () => {
const f = createNativeWorldPhaseBattleFixture({ skills: true, skilled: true, harass: false, support: 3,
training: { ixians: 'suk-graduate', tleilaxu: 'killer-medic', guild: 'sandmaster' } });
assert.equal(f.beforeFirstMentat.strongholdCards!.owners.arrakeen, null);
assert.equal(f.afterFirstMentat.strongholdCards!.owners.arrakeen, f.owner);
assert.equal(viewGame(f.game, f.owner).battle!.strongholdEffects[f.owner], 'arrakeen');
let game = advanceNativeWorldBattle(revealNativeWorldPhaseBattle(f), g => g.decision?.kind === 'sukRescue', [],
{ casualties: { normal: 0, elite: 3 } });
assert.ok(game.decision?.kind === 'sukRescue');
assert.deepEqual(game.pendingSukRescue!.pool, [{ key: f.location, normal: 3, elite: 3 }]);
assert.deepEqual(game.pendingSukRescue!.losses, { normal: 0, elite: 3, paidNormal: 0, paidElite: 3 });
assert.equal(player(game, f.owner).spice, player(f.game, f.owner).spice - 1,
'Only the real earned Arrakeen benefit covers two of the three selected support');
assert.equal(player(game, f.owner).tanks, 3);
assert.equal(player(game, f.owner).elites!.tanks, 0);
assert.ok(game.decision.options.every(o => o.normal === 0), 'The three original native fee normals are not rescuable');
const choice = game.decision.options.findIndex(o => o.elite === 3 && o.kept?.key === f.location && o.kept.kind === 'elite');
assert.ok(choice >= 0);
game = applyAction(game, f.owner, { type: 'decision', event: game.decision.event, choice });
game = settleNativeWorldPhaseBattle(game, f);
assert.equal(game.decision?.kind, 'faceDance', 'The actual native spectator continuation resumes after Suk and card disposal');
assert.deepEqual(pool(game, f.owner, 'homeworld:ixians'), { normal: 4, elite: 3 });
assert.deepEqual(combatArmy(game, f.owner, 'arrakeen'), { normal: 3, elite: 1 });
assert.equal(game.lastBattleContext!.sukRescue!.physical!.source, 'arrakis');
assert.deepEqual(game.lastBattleContext!.sukRescue!.physical!.destinations,
{ 'homeworld:ixians': { normal: 0, elite: 2 } });
const done = finishNativeWorldPhaseBattle(game, f);
assert.equal(done.lastBattleContext!.winner, f.owner);
assert.equal(done.lastBattleContext!.sukRescue!.completed, true);
assert.equal(player(done, f.owner).tanks, 3);
assert.equal(player(done, f.owner).elites!.tanks, 0);
assert.equal(player(done, f.owner).battleLosses, 6);
assert.equal(done.discard.filter(c => c.id === REINFORCEMENTS_CARD).length, 1);
assert.equal(done.strongholdCards!.owners.arrakeen, f.owner);
assert.ok(done.techTokens);
assertNativeWorldBattleInventory(done, f.afterSetup);
});
void test('Basic all14 visitor Suk keeps the only committed Cyborg before native Face Dance returns it and kills the trained disc', () => {
const f = createNativeWorldPhaseForeignBattleFixture({ advanced: false, skills: true, skilled: true, typedAftercare: true,
training: { ixians: 'suk-graduate', tleilaxu: 'killer-medic', guild: 'sandmaster' } });
assert.equal(viewGame(f.game, f.owner).battle!.strongholdEffects[f.owner], null);
let game = advanceNativeWorldBattle(revealNativeWorldBattle(f), g => g.decision?.kind === 'sukRescue', [],
{ casualties: { normal: 0, elite: 1 } });
assert.ok(game.decision?.kind === 'sukRescue');
assert.deepEqual(game.pendingSukRescue!.losses, { normal: 0, elite: 1, paidNormal: 0, paidElite: 0 });
assert.equal(player(game, f.owner).tanks, 3);
assert.equal(player(game, f.owner).elites!.tanks, 2);
const choice = game.decision.options.findIndex(o => o.elite === 1 && o.kept?.key === f.location && o.kept.kind === 'elite');
assert.ok(choice >= 0);
game = applyAction(game, f.owner, { type: 'decision', event: game.decision.event, choice });
game = advanceNativeWorldBattle(game, g => g.decision?.kind === 'faceDance');
assert.equal(game.pendingIxSubstitution, undefined, 'The single board Cyborg was saved, while the two fee Cyborgs cannot supply substitution');
assert.deepEqual(combatArmy(game, f.owner, f.location), { normal: 9, elite: 1 });
assert.equal(game.decision!.player, f.opponent);
assert.equal(game.discard.filter(c => c.id === REINFORCEMENTS_CARD).length, 1);
game = applyAction(game, f.opponent, { type: 'decision', reveal: true, sources: {} });
game = advanceNativeWorldBattle(game, nativeWorldBattleFinished);
assert.deepEqual(combatArmy(game, f.owner, f.location), { normal: 0, elite: 0 });
assert.deepEqual(pool(game, f.owner, 'homeworld:ixians'), { normal: 9, elite: 2 });
assert.equal(player(game, f.owner).tanks, 3);
assert.equal(player(game, f.owner).elites!.tanks, 2);
assert.equal(player(game, f.owner).leaders.find(l => l.id === f.planActions[0].action.leader)!.dead, true);
assert.ok(!game.leaderSkills!.assignments.some(a => a.leader === f.planActions[0].action.leader));
assert.equal(game.leaderSkills!.deck.filter(skill => skill === 'suk-graduate').length, 1,
'Original native Face Dance death returns the singleton trained card instead of retaining a dead assignment');
assertNativeWorldBattleInventory(game, f.afterSetup);
});
+14 -10
View File
@@ -1,21 +1,21 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, RuleError, viewGame } from '../game/engine';
import { createIndependentCardModulesFixture } from './fixture-independent-card-modules';
import { createNativeWorldMarkerFixture, nativeWorldMarkerPlan } from './fixture-native-world-marker';
import {
createRicheseReinforcementsFixture, finishRicheseReinforcementsBattle,
finishRicheseReinforcementsBattle,
prepareRicheseReinforcementsPlan, RICHESE_REINFORCEMENTS,
richeseReinforcementsPlayer, sealRicheseReinforcementsPlan,
} from './fixture-richese-reinforcements';
function markerFixture(advanced: boolean) {
const setup = createIndependentCardModulesFixture({ advanced, homeworlds: true,
tech: true, strongholds: advanced, expansions: ['choam'], factions: ['richese', 'guild', 'atreides'] });
return createRicheseReinforcementsFixture({ advanced, family: 'choam', marker: 3,
...(advanced ? {} : { reserves: 5 }), initial: setup.initial });
return createNativeWorldMarkerFixture({ advanced, tech: true, strongholds: advanced,
family: 'choam', marker: 3, skills: true, skilled: true, opponentDial: 8,
training: { richese: advanced ? 'spice-banker' : 'mentat' },
...(advanced ? { bankerSpice: 1 } : { reserves: 5 }) });
}
void test('Basic native World Tech income cannot fund a marker fee from future withdrawal counters', () => {
void test('Basic all14 native World Tech income and Mentat strength cannot fund a marker fee from future withdrawal counters', () => {
const f = markerFixture(false);
const before = structuredClone(f.game);
const preview = viewGame(f.game, f.richese).battle!.reinforcements!;
@@ -25,7 +25,7 @@ void test('Basic native World Tech income cannot fund a marker fee from future w
delete raw.reinforcementsSources;
assert.throws(() => applyAction(f.game, f.richese, raw), RuleError);
assert.deepEqual(f.game, before);
const first = prepareRicheseReinforcementsPlan(f.game, f.plans.harass);
const first = prepareRicheseReinforcementsPlan(f.game, nativeWorldMarkerPlan(f, 'harass'));
const done = finishRicheseReinforcementsBattle(sealRicheseReinforcementsPlan(first, f.plans.opponent));
const owner = richeseReinforcementsPlayer(done, f.richese);
assert.equal(owner.reserves, 3);
@@ -33,9 +33,12 @@ void test('Basic native World Tech income cannot fund a marker fee from future w
assert.ok(owner.hand.some(card => card.id === RICHESE_REINFORCEMENTS));
});
void test('Advanced native World Tech and Strongholds keep marker materialization and the later reserve fee disjoint', () => {
void test('Advanced all14 native World Tech and Strongholds keep Banker support, marker materialization and reserve fee disjoint', () => {
const f = markerFixture(true);
const first = prepareRicheseReinforcementsPlan(f.game, f.plans.reinforcements);
const beforeSpice = richeseReinforcementsPlayer(f.game, f.richese).spice;
const plan = nativeWorldMarkerPlan(f);
assert.deepEqual(plan.action.reinforcementsSources, { [f.source.homeworld]: { normal: 3, elite: 0 } });
const first = prepareRicheseReinforcementsPlan(f.game, plan);
assert.equal(richeseReinforcementsPlayer(first, f.richese).reserves, 20);
assert.ok(richeseReinforcementsPlayer(first, f.richese).noField!.deployed);
const revealed = sealRicheseReinforcementsPlan(first, f.plans.opponent);
@@ -45,6 +48,7 @@ void test('Advanced native World Tech and Strongholds keep marker materializatio
const owner = richeseReinforcementsPlayer(done, f.richese);
assert.equal(owner.reserves, 14);
assert.equal(owner.tanks, 6);
assert.equal(owner.spice, beforeSpice - 2, 'One actual force support and one own Banker commitment settle separately.');
const home = viewGame(done, f.richese).homeworlds?.worlds?.find(world => world.native === f.richese);
assert.deepEqual(home?.forces[f.richese], { normal: 14, elite: 0 });
assert.equal(done.discard.filter(card => card.id === RICHESE_REINFORCEMENTS).length, 1);
+76
View File
@@ -174,3 +174,79 @@ void test('Advanced native CHOAM + Tech: unlimited paid/free returns retain thei
const completed = programme.finish(game);
assert.equal(player(completed.afterPhaseEnd, native).spice, player(f.game, native).spice - 10);
});
void test('Basic all14 native Ix: actual paid cyborgs accrue Banker separately from once-only phase-end Axlotl', () => {
const f = createNativeWorldRecruitsFixture({ skills: true, tech: true, faction: 'ixians',
training: { emperor: 'mentat', ixians: 'swordmaster-of-ginaz', guild: 'spice-banker' } });
const native = f.native, banker = 'guild';
const programme = axlotlProgramme(f.game, native);
const deferred = (game: Game) => viewGame(game, banker).spiceBankerIncome!.deferred
.find(row => row.owner === banker)?.amount ?? 0;
const originalBankerWallet = player(f.game, banker).spice;
const paid = requestNativeWorldRecruits(f.game, native, 3, 1, 0);
assert.deepEqual({ cost: paid.price, wallet: paid.walletDelta, free: paid.free },
{ cost: 3, wallet: 3, free: 2 });
assert.equal(deferred(paid.game), 0, 'The actual three-spice bank payment is below Banker threshold');
let game = applyAction(paid.game, native, { type: 'ready' });
game = applyAction(game, f.owner, play);
assert.equal(player(game, native).spice, player(f.game, native).spice - 3);
assert.equal(player(game, native).revived, 3);
assert.equal(player(game, native).freeForcesRevived, 2);
const crossing = requestNativeWorldRecruits(game, native, 2, 2, 2);
assert.deepEqual(crossing.nativeAfter, { normal: 2, elite: 3 });
assert.equal(crossing.price, 0);
assert.equal(deferred(crossing.game), 0, 'Typed free deposits are not qualifying bank payments');
assert.equal(viewGame(crossing.game, native).revival.homeworldBonus, 0);
assert.equal(viewGame(crossing.game, native).revival.freeRemaining, 0);
rejectsUnchanged(crossing.game, native, { type: 'revive', amount: 2, elite: 2 });
const last = requestNativeWorldRecruits(crossing.game, native, 2, 1, 0);
assert.deepEqual({ cost: last.price, wallet: last.walletDelta, free: last.free },
{ cost: 5, wallet: 5, free: 0 });
assert.deepEqual(last.nativeAfter, { normal: 3, elite: 4 });
assert.equal(player(last.game, native).revived, 7);
assert.equal(player(last.game, native).freeForcesRevived, 4);
assert.equal(player(last.game, native).tanks, 7);
assert.equal(player(last.game, native).elites!.tanks, 0);
assert.equal(deferred(last.game), 1);
const receipt = last.game.spiceBankerIncome!.sources.at(-1)!;
assert.equal(receipt.source.kind, 'force-revival');
assert.deepEqual(receipt.source.bankLegs, [{ payer: native, amount: 5 }]);
assert.equal(receipt.grant?.owner, banker);
assert.equal(player(last.game, banker).spice, originalBankerWallet,
'Normal Banker income stays in front of the shield until Mentat');
const completed = programme.finish(last.game);
assert.equal(deferred(completed.afterPhaseEnd), 1, 'Revival end pays Axlotl, not Banker');
assert.equal(deferred(completed.afterShipment), 1);
assert.equal(player(completed.afterPhaseEnd, native).spice, player(f.game, native).spice - 8);
assert.equal(player(completed.afterPhaseEnd, banker).spice,
originalBankerWallet + (programme.owner === banker ? programme.income : 0));
game = completed.afterShipment;
for (let count = 0; count < 300 && !(game.phase === 7 && nativeWorldRecruitsClean(game)); count++) {
if (!game.battle && game.phase === 6 && nativeWorldRecruitsClean(game)) {
const choice = viewGame(game, game.active!).battleChoices[0]; assert.ok(choice);
game = applyAction(game, choice.chooser, { type: 'chooseBattle', territory: choice.territory,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker });
} else if (game.battle && !game.battle.revealed && nativeWorldRecruitsClean(game) &&
!game.battle.preparation && game.battle.preLeader?.closed !== false) {
const actor = [game.battle.attacker, game.battle.defender].find(id => !game.battle!.plans[id]);
assert.ok(actor);
const leader = player(game, actor).leaders.filter(disc => !disc.dead && !disc.usedAt)
.sort((a, b) => b.strength - a.strength || a.id.localeCompare(b.id))[0];
assert.ok(leader);
game = applyAction(game, actor, { type: 'battlePlan', leader: leader.id, dial: 0, support: 0 });
} else {
const step = nextNativeWorldRecruitsStep(game); assert.ok(step);
game = applyAction(game, step.actor, step.action);
}
}
assert.equal(game.phase, 7);
assert.equal(deferred(game), 1);
const beforeMentatWallet = player(game, banker).spice;
const mentat = advanceNativeWorldRecruits(game, state => state.phase === 8 && nativeWorldRecruitsClean(state));
assert.equal(deferred(mentat), 0);
assert.equal(player(mentat, banker).spice, beforeMentatWallet + 1,
'The one actual qualifying bank invoice is collected only at original Mentat');
assert.equal(mentat.techTokens!.axlotl.spice, 0);
assert.equal(mentat.techTokens!.axlotl.triggeredTurn, 2);
assertNativeWorldRecruitsInventory(mentat);
});
+29
View File
@@ -227,3 +227,32 @@ for (const advanced of [false, true]) {
continueToShipment(game);
});
}
void test('Advanced all14 native Tleilaxu keeps its unlimited return, accepted low-World invoice and separate own free income', () => {
const f = createNativeWorldRecruitsFixture({ advanced: true, skills: true, faction: 'tleilaxu',
training: { emperor: 'mentat', tleilaxu: 'swordmaster-of-ginaz', guild: 'spice-banker' } });
const active = applyAction(f.game, f.owner, play);
const returned = requestNativeWorldRecruits(active, f.native, 11);
assert.deepEqual({ price: returned.price, free: returned.free, wallet: returned.walletDelta },
{ price: 5, free: 6, wallet: 4 });
assert.deepEqual(returned.nativeAfter, { normal: 11, elite: 0 });
assert.equal(nativeWorldRecruitsPlayer(returned.game, f.native).spice, 1);
assert.equal(nativeWorldRecruitsPlayer(returned.game, f.native).revived, 11);
assert.equal(nativeWorldRecruitsPlayer(returned.game, f.native).freeForcesRevived, 6);
assert.equal(nativeWorldRecruitsPlayer(returned.game, f.native).tanks, 9);
const current = viewGame(returned.game, f.native).revival;
assert.deepEqual({ limit: current.limit, remaining: current.forcesRemaining,
free: current.freeRemaining, bonus: current.homeworldBonus },
{ limit: 20, remaining: 9, free: 0, bonus: 0 });
const receipt = returned.game.spiceBankerIncome!.sources.at(-1)!;
assert.equal(receipt.source.kind, 'force-revival');
assert.deepEqual(receipt.source.bankLegs, [{ payer: f.native, amount: 5 }],
'The actual paid invoice, not its net four-spice wallet delta, is the bank leg');
assert.equal(receipt.grant?.owner, 'guild');
const next = continueToShipment(returned.game);
assert.equal(nativeWorldRecruitsPlayer(next, f.native).spice, 1);
assert.equal(nativeWorldRecruitsPlayer(next, f.native).revived, 11);
assert.equal(nativeWorldRecruitsPlayer(next, f.native).freeForcesRevived, 6);
assert.equal(viewGame(next, 'guild').spiceBankerIncome!.deferred
.find(row => row.owner === 'guild')?.amount, 1, 'Banker is not paid at Revival end');
});
@@ -0,0 +1,94 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, normalizeAutomaticGame, type Action, type Game } from '../game/engine';
import { nativeHomeworldArmy } from './fixture-homeworld-native-e1e2-modules';
import {
advanceSelectedDeckSkills, assertSelectedDeckSkillsInventory, createSelectedDeckSkillsProgramme,
createSelectedDeckSkillsRevivalCase, selectedDeckSkillsClean, selectedDeckSkillsPlayer,
selectedDeckSkillsQuote,
} from './fixture-selected-deck-skills';
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game;
void test('bare Basic Ix/CHOAM table deals native setup and continues a paid shipment without independent cards', () => {
const f = createSelectedDeckSkillsProgramme();
const owner = f.owner, guild = f.guild;
assert.equal(f.afterSetup.richeseCache!.length, 10, 'A seated Richese keeps its separate ten-card cache.');
// The real Ix card draft happens before any module technique.
assert.equal(f.started.setupStage, 'skillTreachery');
assert.ok(f.ixOffer && f.ixOffer.after.setupStage === 'leaderSkills');
assert.equal(f.ixOffer.after.leaderSkills!.assignments.length, 0);
// Real spice pays for a real shipment into the untouched Guild army.
const committed = selectedDeckSkillsPlayer(f.shipment.before, guild).forces['tueks_sietch:5'];
assert.equal(selectedDeckSkillsPlayer(f.shipment.before, owner).spice -
selectedDeckSkillsPlayer(f.shipment.after, owner).spice, 2);
assert.equal(selectedDeckSkillsPlayer(f.shipment.after, owner).forces['tueks_sietch:5'], 2);
assert.equal(selectedDeckSkillsPlayer(f.shipment.after, guild).forces['tueks_sietch:5'], committed);
// The trained Mentat really changes the battle total.
const quote = selectedDeckSkillsQuote(f.game);
assert.equal(quote.winner, owner);
assert.deepEqual(quote.leaderSkillBonuses.attacker.applied,
[{ skill: 'mentat', amount: 2, mode: 'skilled' }]);
assert.equal(quote.leaderSkillBonuses.defender.bonus, 0);
// The same position continues into the next real phase with real losses.
const done = advanceSelectedDeckSkills(f.game, g => !g.battle && selectedDeckSkillsClean(g), f.actions);
assert.equal(done.phase, 7);
assert.equal(selectedDeckSkillsPlayer(done, owner).forces['tueks_sietch:5'], 2, 'The winner keeps the territory.');
assert.equal(selectedDeckSkillsPlayer(done, guild).forces['tueks_sietch:5'], undefined);
assert.equal(selectedDeckSkillsPlayer(done, guild).tanks, selectedDeckSkillsPlayer(f.game, guild).tanks + committed);
assertSelectedDeckSkillsInventory(done);
});
void test('the same bare table with original Homeworlds ships out of its real native pools', () => {
const f = createSelectedDeckSkillsProgramme({ homeworlds: true });
const owner = f.owner, home = `homeworld:${selectedDeckSkillsPlayer(f.shipment.before, owner).faction}`;
assert.ok(f.afterSetup.homeworlds?.custody);
const before = nativeHomeworldArmy(f.shipment.before, owner, home);
const after = nativeHomeworldArmy(f.shipment.after, owner, home);
assert.equal(before.normal + before.elite - after.normal - after.elite, 2);
assert.equal(selectedDeckSkillsPlayer(f.shipment.before, owner).spice -
selectedDeckSkillsPlayer(f.shipment.after, owner).spice, 2);
assert.equal(selectedDeckSkillsPlayer(f.shipment.after, owner).forces['tueks_sietch:5'], 2);
assertSelectedDeckSkillsInventory(f.shipment.after);
const done = advanceSelectedDeckSkills(f.game, g => !g.battle && selectedDeckSkillsClean(g), f.actions);
assert.equal(done.phase, 7);
assertSelectedDeckSkillsInventory(done);
});
void test('a base-only table needs only its selected ordinary deck, even when no seat uses that deck', () => {
const f = createSelectedDeckSkillsProgramme({ factions: ['emperor', 'atreides', 'guild'], expansions: ['ecaz'] });
assert.equal(selectedDeckSkillsQuote(f.game).winner, f.owner);
const done = advanceSelectedDeckSkills(f.game, g => !g.battle && selectedDeckSkillsClean(g), f.actions);
assert.equal(done.phase, 7);
assertSelectedDeckSkillsInventory(done);
});
void test('a real battle death keeps the native Advanced-only foreign ghola guard on a bare table', () => {
const basic = createSelectedDeckSkillsRevivalCase({ advanced: false });
const advanced = createSelectedDeckSkillsRevivalCase({ advanced: true });
for (const f of [basic, advanced]) {
assert.equal(f.game.turn, 2);
assert.equal(f.game.phase, 4);
assert.equal(f.game.players.flatMap(p => p.leaders).filter(l => l.id === f.dead).length, 1);
assert.equal(f.game.leaderSkills!.assignments
.some(a => a.owner === f.tleilaxu && a.leader === f.trainer), false,
'The poisoned trainer returned its physical technique.');
assertSelectedDeckSkillsInventory(f.game);
}
const action: Action = { type: 'reviveForeignGhola', leader: basic.dead };
const untouched = reload(basic.game);
assert.throws(() => applyAction(basic.game, basic.tleilaxu, action), /advanced Tleilaxu/);
assert.deepEqual(basic.game, untouched, 'A Basic bare table pays nothing and revives nothing.');
const disc = selectedDeckSkillsPlayer(advanced.game, advanced.guild).leaders.find(l => l.id === advanced.dead)!;
const cost = Math.ceil(disc.strength / 2), purse = selectedDeckSkillsPlayer(advanced.game, advanced.tleilaxu).spice;
const revived = applyAction(reload(advanced.game), advanced.tleilaxu, action);
assert.equal(disc.dead, true, 'The caller-owned input keeps its own physical disc.');
assert.equal(selectedDeckSkillsPlayer(revived, advanced.guild).leaders.find(l => l.id === advanced.dead)!.dead, false);
assert.equal(selectedDeckSkillsPlayer(revived, advanced.guild).leaders.find(l => l.id === advanced.dead)!.gholaBy,
advanced.tleilaxu);
assert.equal(selectedDeckSkillsPlayer(revived, advanced.tleilaxu).spice, purse - cost);
assert.ok(!revived.response && !revived.pendingRevival);
assert.deepEqual(normalizeAutomaticGame(reload(revived)), revived,
'The Advanced bare selected-deck table keeps the foreign ghola custody authorized.');
assertSelectedDeckSkillsInventory(revived);
});
+2 -19
View File
@@ -2,9 +2,8 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { createElement } from 'react';
import { applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { basicTleilaxuLeaderSkillsProfile } from '../game/leader-skill-profile';
import { registerHooks } from 'node:module';
const aliases = registerHooks({resolve(specifier,context,next){return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier,context);}});
const { RihaniChoice } = await import('../components/rihani-decipherer');
@@ -55,28 +54,12 @@ function exchange(g: Game, owner: string) {
return act(g, owner, { type: 'decision', event, cards: [r.drawn[0], r.eligible[0]] });
}
void test('all fourteen skills admit genuine Basic Tleilaxu setup while Richese and module gates remain closed', () => {
void test('all fourteen skills assign genuine Basic Tleilaxu discs through original setup', () => {
for (const card of LEADER_SKILL_CARDS) {
const game = completedTleilaxuSkillsGame({ requestedSkill: card.id });
assert.equal(game.leaderSkills!.assignments.find(a => a.owner === 't')!.skill, card.id);
assert.equal(basicTleilaxuLeaderSkillsProfile(game), true);
assert.equal(basicTleilaxuLeaderSkillsProfile(viewGame(game, 'a')), true);
stable(game);
}
// Native E1 Discovery Leader Skills are now admitted for this fresh lobby;
// unsupported Discovery overlays still reject.
const discovery = createGame('TLEIGATE',newPlayer('t','Tleilaxu','tleilaxu'),false,['ix']);
joinGame(discovery,newPlayer('e','Emperor','emperor'));discovery.players.forEach(p => {p.ready = true;});
discovery.discoveryEnabled = true;
assert.equal(basicTleilaxuLeaderSkillsProfile(initializeLeaderSkillsGameForAudit(discovery)), true);
for (const mutate of [(g: Game) => {g.players[1] = newPlayer('x','Richese','richese');},
(g: Game) => {g.expansions.push('choam');},
(g: Game) => {g.discoveryEnabled = true; g.players[1] = newPlayer('x','Richese','richese');},
(g: Game) => {g.discoveryEnabled = true; g.expansions.push('choam');}]) {
const g = createGame('TLEIGATE',newPlayer('t','Tleilaxu','tleilaxu'),false,['ix']);
joinGame(g,newPlayer('e','Emperor','emperor'));g.players.forEach(p => {p.ready = true;});mutate(g);
const before = JSON.stringify(g);assert.throws(() => initializeLeaderSkillsGameForAudit(g));assert.equal(JSON.stringify(g),before);
}
});
void test('native Rihani keeps a private Face Dancer, returns only an old unrevealed card and survives JSON across every choice', () => {
+65
View File
@@ -5,6 +5,7 @@ import { combatArmy } from '../game/combat-location';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
import { harassHomeworldPool as pool, finishHarassHomeworldBattle } from './fixture-harass-homeworld';
import { advanceNativeWorldBattle } from './fixture-native-world-battle';
import {
finishReinforcementsHomeworldBattle, revealReinforcementsHomeworldBattle,
} from './fixture-reinforcements-homeworld';
@@ -188,3 +189,67 @@ void test('foreign World R+H: original split normal fee precedes exact normal/el
assert.equal(done.battle, null); assert.ok(worldPhaseBattleClean(done));
assertWorldPhaseBattleInventory(done);
});
void test('all14 Emperor visitor chooses original typed H returns or committed-only Suk rescue after the same split normal fee', () => {
const f = createWorldPhaseVisitorBattleFixture({ skills: true, skilled: true,
training: { emperor: 'suk-graduate', guild: 'swordmaster-of-ginaz', atreides: 'sandmaster' } });
assert.deepEqual(f.sources, {
'homeworld:emperor': { normal: 1, elite: 0 }, 'homeworld:emperor:salusa': { normal: 2, elite: 0 },
});
assert.equal(viewGame(f.game, f.owner).battle!.strongholdEffects[f.owner], null,
'Original unused modules never subsidize a foreign World');
const trainer = f.planActions[0].action.leader;
const untrained = player(f.game, f.owner).leaders.filter(l => !l.dead && !l.usedAt && l.id !== trainer)
.sort((a, b) => b.strength - a.strength || a.id.localeCompare(b.id))[0];
assert.ok(untrained);
f.planActions[0].action.leader = untrained.id;
const withdrawn = finishHarassHomeworldBattle(revealReinforcementsHomeworldBattle(f),
{ returns: { [f.location]: { normal: 2, elite: 3 } } });
assert.deepEqual(pool(withdrawn, f.owner, 'homeworld:emperor'), { normal: 11, elite: 0 });
assert.deepEqual(pool(withdrawn, f.owner, 'homeworld:emperor:salusa'), { normal: 0, elite: 4 });
assert.equal(player(withdrawn, f.owner).tanks, 5);
assert.equal(player(withdrawn, f.owner).elites!.tanks, 1);
assert.equal(withdrawn.lastBattleContext!.sukRescue, undefined,
'A concealed native trainer used as a bluff grants neither normal nor skilled Suk rescue');
assert.deepEqual(viewGame(f.game, f.owner).battle!.traitorVoters, [f.opponent]);
assertWorldPhaseBattleInventory(withdrawn);
// The real alternative keeps H in hand and lets the selected surviving disc
// rescue only the two actually committed visitor casualties.
f.planActions[0].action.defense = null;
f.planActions[0].action.leader = trainer;
let game = advanceNativeWorldBattle(revealReinforcementsHomeworldBattle(f), g => g.decision?.kind === 'sukRescue', [],
{ casualties: { normal: 1, elite: 1 } });
assert.ok(game.decision?.kind === 'sukRescue');
assert.deepEqual(game.pendingSukRescue!.pool, [{ key: f.location, normal: 3, elite: 4 }]);
assert.deepEqual(game.pendingSukRescue!.losses, { normal: 1, elite: 1, paidNormal: 1, paidElite: 0 });
assert.equal(player(game, f.owner).tanks, 3);
assert.equal(player(game, f.owner).elites!.tanks, 0);
assert.ok(game.decision.options.every(o => o.normal + o.elite <= 2), 'The original fee3N cannot enlarge the rescue');
const choice = game.decision.options.findIndex(o => o.normal === 1 && o.elite === 1 && o.kept?.kind === 'normal');
assert.ok(choice >= 0);
const action = { type: 'decision', event: game.decision.event, choice };
const before = structuredClone(game);
assert.throws(() => applyAction(game, f.owner, action), 'Two native homes need a sealed typed rescue allocation');
assert.throws(() => applyAction(game, f.owner, { ...action,
destinations: { [f.location]: { normal: 0, elite: 1 } } }), 'Foreign visitor custody is never a reserve return destination');
assert.deepEqual(game, before);
game = applyAction(game, f.owner, { ...action,
destinations: { 'homeworld:emperor:salusa': { normal: 0, elite: 1 } } });
game = finishReinforcementsHomeworldBattle(game);
assert.equal(game.lastBattleContext!.winner, f.owner);
assert.equal(game.lastBattleContext!.sukRescue!.physical!.source, 'visitor-homeworld');
assert.deepEqual(game.lastBattleContext!.sukRescue!.physical!.destinations,
{ 'homeworld:emperor:salusa': { normal: 0, elite: 1 } });
assert.deepEqual(pool(game, f.owner, 'homeworld:emperor'), { normal: 9, elite: 0 });
assert.deepEqual(pool(game, f.owner, 'homeworld:emperor:salusa'), { normal: 0, elite: 2 });
assert.deepEqual(pool(game, f.owner, f.location), { normal: 3, elite: 3 });
assert.equal(player(game, f.owner).tanks, 3);
assert.equal(player(game, f.owner).elites!.tanks, 0);
assert.equal(player(game, f.owner).battleLosses, 5);
assert.equal(player(game, f.owner).spice, player(f.game, f.owner).spice - 1,
'Native Worlds cannot borrow Arrakeen support or refund the original paid support');
assert.equal(game.discard.filter(c => c.id === REINFORCEMENTS_CARD).length, 1);
assert.ok(player(game, f.owner).hand.some(c => c.id === HARASS_WITHDRAW_CARD));
assertWorldPhaseBattleInventory(game);
});
+31 -4
View File
@@ -3,7 +3,8 @@ import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { ownedTech } from '../game/tech-tokens';
import { RECRUITS_CARD_ID } from '../game/recruits';
import { assertRecruitsHomeworldInventory } from './fixture-recruits-homeworld';
import { advanceRecruitsHomeworld, assertRecruitsHomeworldInventory,
recruitsHomeworldClean } from './fixture-recruits-homeworld';
import {
createWorldPhaseRecruitsFixture, worldPhaseRecruitsNativePool as pool,
worldPhaseRecruitsPlayer as player,
@@ -108,9 +109,12 @@ for (const options of cases) {
});
}
void test('A bulk native low-population request locks its quoted seven, unlike the six separate typed returns', () => {
const f = createWorldPhaseRecruitsFixture();
const { fremen } = f.actors;
void test('Advanced all14 Worlds + Tech + earned Strongholds: a bulk low-World seven differs from separate typed six through phase settlement', () => {
const f = createWorldPhaseRecruitsFixture({ advanced: true, strongholds: true, skills: true,
seatIds: ['world-emperor', 'world-fremen', 'world-atreides', 'world-guild'],
training: { emperor: 'swordmaster-of-ginaz', fremen: 'mentat',
atreides: 'warmaster', guild: 'spice-banker' } });
const { fremen, atreides, owner, guild } = f.actors;
const bulk = applyAction(f.beforeFremen, fremen, { type: 'revive', amount: 7, elite: 0 });
assert.equal(player(bulk, fremen).revived, 7);
assert.equal(player(bulk, fremen).freeForcesRevived, 7);
@@ -120,5 +124,28 @@ void test('A bulk native low-population request locks its quoted seven, unlike t
assert.deepEqual(pool(bulk, fremen), { normal: 9, elite: 0 });
assert.equal(bulk.techTokens!.axlotl.spice, f.expected.income,
'Another eligible bulk return cannot accrue a second payment in the same original phase');
assert.equal(player(f.afterReturns, fremen).revived, 6,
'The separate typed request crosses the native population threshold before its followup');
assert.equal(player(f.afterReturns, fremen).elites!.reserves, 1);
assert.equal(viewGame(f.afterTypedReturn, fremen).revival.eliteRemaining, 0);
rejectsUnchanged(f.afterTypedReturn, fremen, { type: 'revive', amount: 1, elite: 1 });
const next = advanceRecruitsHomeworld(bulk,
game => game.phase === 5 && recruitsHomeworldClean(game));
assert.equal(next.turn, 2);
assert.equal(player(next, fremen).revived, 7);
assert.equal(player(next, fremen).freeForcesRevived, 7);
assert.deepEqual(pool(next, fremen), { normal: 9, elite: 0 });
assert.equal(player(next, fremen).spice, player(f.beforeFremen, fremen).spice);
assert.equal(player(next, atreides).revived, 5);
assert.equal(player(next, atreides).freeForcesRevived, 4);
assert.equal(player(next, atreides).spice, player(f.game, atreides).spice - 2,
'Bulk free return and phase end do not refund the original paid reopening ledger');
assert.equal(next.techTokens!.axlotl.spice, 0);
assert.equal(next.techTokens!.axlotl.triggeredTurn, 2);
assert.equal(player(next, owner).spice, player(bulk, owner).spice + f.expected.income);
assert.equal(viewGame(next, guild).spiceBankerIncome!.deferred.length, 0,
'Free typed or bulk deposits and the two-spice invoice never qualify normal Banker');
assert.deepEqual(next.strongholdCards, f.game.strongholdCards);
assertRecruitsHomeworldInventory(next);
assertRecruitsHomeworldInventory(bulk);
});
+3 -2
View File
@@ -4,7 +4,7 @@ import type { Game } from '../game/engine';
import { richeseCards } from '../game/richese-cards';
import { ecazTreacheryCards } from '../game/ecaz-cards';
import { traitorDeck } from '../game/traitors';
import { basicExpansionLeaderSkillsProfile } from '../game/leader-skill-profile';
import { basicMoritaniLeaderSkillsProfile, selectedDeckLeaderSkillsProfile } from '../game/leader-skill-profile';
import { validateLeaderSkills } from '../game/leader-skills';
import { ownedStrongholdCards, STRONGHOLD_CARDS } from '../game/stronghold-cards';
@@ -25,7 +25,8 @@ export function sampleInventory(game: Game) {
.map((card) => card.id)
.sort(),
// The bounded Moritani preview has a separate public retired-card zone.
traitors: game.expansions.length && !basicExpansionLeaderSkillsProfile(game) &&
traitors: game.expansions.length && !(game.advanced === false &&
(basicMoritaniLeaderSkillsProfile(game) || selectedDeckLeaderSkillsProfile(game))) &&
!game.moritaniAssassinatePreview
? null
: traitorDeck(