Compose standalone E3 Skills and independent cards with native power defaults

Restore fresh Ecaz coalition and Advanced Moritani initialization; retain printed native continuations and selected-family card eligibility. Validate Mentat fallback hands against explicitly selected independent cards. Frozen25-file274/274, types/lint/build and genuine runtime/mobile entry proofs pass. No save retrofit or deployment.
This commit is contained in:
Quintine committed 2026-10-17 20:36:01 +11:00
1 parent 2ad947af54
commit fde44baab5
25 files changed
+1838 -103

No files matched your search

+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.
**Standalone E3 independent cards/native defaults — 17 October.** Fresh
standalone Ecaz OR Moritani/classic all14 Skills now retains the three
independent cards without other modules. Original Advanced Skills/Harkonnen,
E3 pair/mixed Skills and shared-Duke guards remain. Plain/card Ecaz initializes
printed Basic/Advanced coalition participation; supported native Advanced
Moritani initializes assassination without an artificial Nexus dependency.
No played-save history is synthesized.
Genuine Ecaz programmes settle fixed/variable armies, card-user fees/returns and
trained Suk into Collection. Genuine Moritani loses, assassinates a different
held unused trained disc, returns one skill and gets one actual Mentat replacement;
plain no-card/no-Skills Harkonnen also retains native capture. Four policies
choose legal native leads/assassination. The same integration exposed and fixed
Mentat rejecting legal independent Specials: selected physical cards now work
as private fallback disclosures, never named weapons or automatic variant activation.
Frozen25-file **274/274**, types/lint, build and targeted runtime programmes pass.
Four native all14/card table smokes reach turn3 or an earlier win; nine no-Skills
native roster/default smokes also continue. Those natural games do not supply
the targeted assassination proof. Actual390px normal Moritani Skills/card entry
passes training, Traitor choice and native six-force setup into Storm without
browser errors/root overflow. Entropy/conserved card staging is explicit; the
Mentat-specific battle position is reduced, not a natural phase history.
No complete-mode, deployment, calibration or deferred assurance claim.
[Card scope and evidence](ECAZ_TREACHERY_RULES.md#standalone-e3-leader-skills-composition--17-october-2026).
**Native Ix/CHOAM independent cards + Skills — 17 October.** Fresh
classic or selected E1/E2 native/base all14 Skills now admits the three
independent cards without other modules. Original36/38/50 ordinary decks and
+7
View File
@@ -10,6 +10,13 @@ setup records Ecaz participation, while both rules bands reuse the selected
plan-owner/skill and fixed-versus-variable army rules. The source profile's
Advanced Harkonnen, Duke assignment and other roster restrictions remain.
[Current standalone scope and evidence](LEADER_SKILLS_RUNTIME.md#standalone-ecaz-coalition--17-october-2026).
Fresh plain and independent-card faction constructors now mark the same
printed Ecaz participation in Basic and Advanced, not only Tech/Nexus/Skills
entries. Basic independent cards no longer reject that mandatory coalition.
Plain no-card/no-Skills Basic and Advanced programmes complete actual setup,
worm alliance, paid shipments and battle cleanup into Collection, conserving
all20 counters per faction. Existing played saves are not retrofitted.
## Native World reserves with Arrakis Occupy — 17 October 2026
+54
View File
@@ -349,3 +349,57 @@ physical card staging are explicit; their armies, paid receipts and phases
follow native actions. This does not certify all combinations, strategic AI,
persistence/privacy assurance or a deployed revision.
## Standalone E3 Leader Skills composition — 17 October 2026
The same printed independent-variant permission now composes original Ecaz OR
Moritani/classic all14 Skills, Basic/Advanced2–6, exact ecaz36, without other
modules. Native offers/assignment, Loyalty, Traitors, starting forces and card
custody stay source-ordered. Advanced Harkonnen, E3 native pairs/mixed families,
shared-Duke training and other module combinations retain their Skills guards.
Fresh plain/card Ecaz starts mark native coalition participation in Basic as
well as Advanced. Basic no longer rejects Occupy merely because cards were
selected. Native fixed Ecaz and variable allied armies remain separate: the
chosen plan user supplies the three-reserve fee and only that user's undialed
counters return through Harass. Suk rescues actual committed losses, not that
reserve fee. Existing Basic rounding and all-outcome fee/return interpretations
remain provisional, not new user or publisher rulings.
Fresh supported native Advanced Moritani starts initialize printed
Assassinate Leaders, including cards with/without Skills; Basic does not gain
it. Normal-call game-long forfeiture, skill-first winner continuation, unused
opposing disc, printed bounty, mandatory card disposal and one actual Mentat
replacement retain their original handlers. No played save is retrofitted.
Bare independent-card rosters also use every actual selected known family
even without Ecaz seated. A conserved Mori/Richese before-plan preview was
blocked before this roster repair and is now available. Actual Basic/Advanced
Ix/Guild with all three selected families reaches Collection: nine paid
Suboids leave one Suboid/two Cyborgs to fund the fee; +2 adds no physical
arrival or support. World and phase-module E3 boundaries are not widened.
Evidence: `tests/fixture-ecaz-independent-skills.ts`,
`tests/ecaz-independent-skills-runtime.test.ts`,
`tests/fixture-moritani-independent-skills.ts`,
`tests/moritani-independent-skills-runtime.test.ts` and
`tests/mentat-question.test.ts`. Native fixtures label scoped entropy and
conserved physical cards/Traitors; armies, payments and phases follow native
actions. The Mori/Richese preview above is a reduced conserved before-plan probe,
not a natural phase trajectory.
Frozen25-file274/274, types/lint and build pass. Actual Basic/Advanced Ecaz
fee/Harass/Suk programmes reach Collection with fee-only3 Tanks, one rescued
counter kept and separate allied2 losses. Four policies select legal leads in
both modes; four native Moritani policies reveal Master Bewt3 and reach one
actual Mentat replacement. Plain no-card/no-Skills Harkonnen gives printed4
assassination and one native captive. Four bounded all14/card tables reach
turn3 or an earlier win, but targeted programmes—not those natural games—prove
assassination. Actual390px normal Moritani entry reaches Storm through genuine
training, Traitor choice and native6 setup without browser errors/root overflow.
The discovered Mentat legal-hand rejection is fixed through explicit selected
inventory in every current/history/reveal consumer. All three Specials remain
private fallback cards, not named weapons. Both normal/trained postures preserve
the hand and seal a legal plan in declared conserved positions. See the
[inventory contract](MENTAT_QUESTION.md#selected-independent-card-inventory--17-october-2026).
No deployed, full-module, strategic calibration or deferred assurance claim follows.
+39
View File
@@ -1,5 +1,44 @@
# Dune implementation status
## 17 October 2026 — Native E3 independent Skills and printed defaults
Fresh standalone Ecaz OR Moritani/classic all14 Skills admits the independent
three-card variant without other modules. Fresh plain/card Ecaz now marks
printed Basic/Advanced coalition participation; supported native Advanced
Moritani starts initialize assassination without a Nexus dependency, including
no-Skills Harkonnen, selected-family and Homeworld occupation compositions.
Existing Skills roster/module guards remain; no played save gains fabricated
normal-call or opportunity history. Bare card rosters retain every selected
known family without requiring Ecaz seated; World/phase-module E3 guards stay.
Disjoint genuine programmes exercise Ecaz selected lead, separate fixed and
variable armies, own reserve fees, typed Harass and trained committed-only Suk.
Moritani's real loss reveals a different held unused native disc, returns its
physical skill once, pays printed bounty and replaces the actual Traitor once
at Mentat. Plain no-card/no-Skills Harkonnen also completes native capture.
Real normal-call forfeiture and a second actual same-faction loss retain their
original once-per-game/faction restrictions.
A real random-hand probe exposed Mentat rejecting independent Specials during
battle creation. Canonical hand validation now receives explicit variant
selection through every question/reveal/history caller. All three cards are
legal private fallback disclosures, never nameable weapons or silently enabled
inventory. Normal and trained continuations preserve custody and seal legal
plans; empty-hand handling and receipt shapes are unchanged.
Frozen25-file **274/274**, types/lint and build pass; seven narrowly selected
Mentat cases and actual control programmes also pass. Four bounded native
all14/card games reach turn3 or an earlier win, with17 battles but no natural
assassination/normal-Mentat disclosure: targeted programmes supply those proofs.
Nine bounded no-Skills native defaults also continue. Actual390px normal
Moritani Skills/cards training, Traitor choice and six-force placement reaches
Storm with no browser errors/root overflow. Native fixtures label scoped
entropy and conserved card/Traitor staging; the Mentat rule position is reduced.
Obsolete Basic exclusion/wording pins and duplicate fixture-forwarding rows are
deleted rather than reworded. Existing build warnings remain. No deployment,
reset, saved-game assurance campaign or complete-mode certification occurs.
[Source, scope and runtime evidence](ECAZ_TREACHERY_RULES.md#standalone-e3-leader-skills-composition--17-october-2026).
## 17 October 2026 — Native independent cards with all14 Skills
The former classic-only independent-card Skills profile now admits selected
+27
View File
@@ -7,6 +7,33 @@ 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).
## Standalone E3 independent cards — 17 October 2026
Fresh native Ecaz OR Moritani/classic all14 Skills now also admits independent
Recruits, Reinforcements and Harass & Withdraw, Basic/Advanced2–6, exact ecaz36
and no other modules. The shared independent/native capability feeds setup,
card previews, skill effects, ordinary bankers and legal AI. Original native
offers, five assignment discs, Loyalty/Traitors and starting forces remain;
Advanced Harkonnen, E3 pairs/mixed families and shared-Duke training stay guarded.
Ecaz retains real allied Arrakis lead selection and separate fixed/variable
armies. Advanced Moritani keeps skill-first winner outcomes, printed unused-disc
assassination and one actual Mentat replacement; Basic does not gain that power.
Card fees/returns and existing provisional timing/Basic rounding do not change.
No played-save conversion or complete-mode acceptance follows.
Frozen25-file274/274, types/lint/build and genuine native programmes pass.
Ecaz fee/withdrawal/Suk reaches Collection; Moritani unused-disc assassination,
one skill return, winner disposal and one Mentat replacement stay physical.
Four policies choose legal native leads and assassination cards. Actual390px
normal Moritani entry completes training/Traitors/native6 into Storm without
errors/overflow. A discovered Mentat hand-validation deadlock is also fixed:
selected independent Specials are valid private fallback disclosures, never
nameable weapons. Normal/trained question continuations retain actual custody.
[Card-source scope](ECAZ_TREACHERY_RULES.md#standalone-e3-leader-skills-composition--17-october-2026).
## Native independent Treachery composition — 17 October 2026
Fresh all14 Skills with the independent Ecaz cards now admits original selected
+19
View File
@@ -6,6 +6,25 @@ New supported Leader Skills battles now offer the normal Mentat question
without a separate preview flag. Public module/mode acceptance remains partial.
[Common skill rules](LEADER_SKILLS_RULES.md) govern assignment, capture, death and the right to conceal a skilled leader.
## Selected independent card inventory — 17 October 2026
A legal independent Special in the opponent's hand could reject battle creation
with a physical-hand error. Canonical validation now receives the actual
independent-variant selection, including current question, private reveal and
historical observation callers. Recruits, Reinforcements and Harass & Withdraw
are legitimate fallback cards when the named weapon is absent; none becomes a
nameable weapon. Selecting the Ecaz faction alone never activates those cards.
Duplicate/forged/unselected cards still reject; empty-hand behavior is unchanged.
`tests/mentat-question.test.ts` covers selected-versus-unselected identities,
private fallback custody/history and normal/trained posture into real sealed
plans. Its setup uses normal lobby module actions; the following conserved
battle position is explicitly staged. Both runtime programmes preserve all
three held Specials and disclose only to the participants. Seven focused
selected cases and the related25-file274/274 pass with types/lint/build.
This is a physical-inventory fix, not a new empty-hand ruling or receipt schema.
## Normal activation and approved UX — 11 October 2026
Normal questions use the existing admitted Leader Skills envelope. Naming
+17 -15
View File
@@ -1,10 +1,11 @@
# Moritani: Advanced Assassinate Leaders preview
Updated 15 October 2026. **Prototyped / Partial**. Original native Advanced
Moritani play is connected through the supported local entry profiles, including
the bounded no-Skills E3 Nexus composition below. Normal public game-start and
publication gates remain closed. This is the battle-loss ability, separate from
the random Assassination Terror token.
Updated 17 October 2026. **Prototyped / Partial**. Supported fresh native
Advanced Moritani starts initialize this printed advantage automatically,
including plain faction starts and the independent-card profile. Normal lobby
access is a labelled preview, not complete-mode or publication acceptance.
This battle-loss ability is separate from the random Assassination Terror token.
Played saves are not assigned fabricated prior-call or opportunity history.
## Authority and Advanced duration cutover
@@ -49,14 +50,15 @@ does not define every other captured or foreign-ghola **target** interaction.
## Connected scope and private information
The original preview supports native Moritani with Atreides, Bene Gesserit,
Emperor, Fremen and Guild opponents, Advanced rules and the Ecaz faction
expansion, without optional modules. The later original E3 Nexus compositions
add Ecaz and, in the bounded no-Skills composition below, Harkonnen. Other
expansion factions, Skills with Harkonnen, Homeworlds, Discoveries and other
combinations retain their separate integration boundaries. The
[bounded all14 native skill composition](MORITANI_LEADER_SKILLS.md#advanced-assassination-composition--4-october-2026)
now extends this same non-Harkonnen roster without opening public starts.
Without Skills, fresh selected native rosters containing Moritani retain this
advantage with their actual distinct Ix/CHOAM/Ecaz family decks, including
Harkonnen, the Ecaz pair and existing Homeworld occupation. Existing module
profiles still govern Nexus, Discovery, Tech and Strongholds. Independent cards
do not require an unrelated Nexus module or suppress assassination.
With Skills, the original standalone Moritani/classic roster still excludes
Advanced Harkonnen and mixed/pair E3. It now also permits the independent
three-card variant without other modules; see
[the all14 native composition](MORITANI_LEADER_SKILLS.md#independent-treachery-composition--17-october-2026).
Winner Suk rescue and Rihani choice finish first; the unspent assassination
receipt waits, then opens its original private choice. Already completed
casualties cannot replay after reveal/decline. A different eligible trained
@@ -85,8 +87,8 @@ requires Continue even for an ineligible hand, conflicting with the user's usual
automatic-action preference. Moritani's uniform-private-step approval remains
separate: the 11 October Mentat approval applies only to that card-show flow.
The bounded native Nexus composition uses its original entry and original
postbattle/assassination continuation, not a separate opt-in assassination shim;
ordinary public starts and room API mode gates remain closed.
postbattle/assassination continuation, not a separate opt-in assassination shim.
Supported normal-lobby previews use the same constructors; complete modes remain gated.
A publicly spent faction or prior normal traitor call can be skipped without consulting hidden eligibility.
All four AI profiles use the same owner-only quote. They choose a legal card or
+31 -4
View File
@@ -1,9 +1,36 @@
# Moritani with Leader Skills
Connected development prototype, 21 September 2026. Basic rules, Moritani and
two through six total seats with base-faction opponents now use the full
fourteen-card Leader Skills deck. Public starts and publication remain gated;
this integrates existing effects, not complete Leader Skills compliance.
Connected development prototype, updated 17 October 2026. Supported standalone
Moritani/classic Basic/Advanced two–six-seat profiles use all fourteen physical
Leader Skills. Normal-lobby access is a labelled preview; original roster and
module guards remain, and complete rules/publication acceptance is unfinished.
## Independent Treachery composition — 17 October 2026
Fresh standalone Moritani/classic all14 Skills now retains the independent
Recruits, Reinforcements and Harass & Withdraw variant, Basic/Advanced2–6,
without other modules. Original36-card setup, five eligible assignment discs,
private offers, Traitors and six-force placement remain. Advanced initializes
the native assassination advantage; Basic does not. The original Advanced
Harkonnen, shared-Duke, mixed-family and paired-E3 Skills guards remain.
Card fees/returns and winner skills continue before native assassination.
The fee is three card-user reserves, never rescued battle dial; a successful
Harass returns only that user's actual undialed forces. An eligible unused
opposing trained disc returns its skill once on assassination, pays printed
bounty and retains the original single Mentat replacement. Source timing
interpretations remain provisional as documented in the card contracts.
Evidence: `tests/fixture-moritani-independent-skills.ts` and
`tests/moritani-independent-skills-runtime.test.ts`. The genuine normal-start
programme pays the native fee, returns exact sectors, loses, reveals unused
Master Bewt3, returns his trained skill once and draws one replacement at
actual Mentat. Four real policies choose that same legal card. Basic resolves
without assassination; plain no-card/no-Skills Harkonnen retains printed4 and
native capture. Frozen25-file274/274, types/lint/build and actual390px original
module setup pass. Conserved hands/Traitors and scoped entropy are labelled;
no full-game, calibration, deployed or played-save assurance claim follows.
## Standalone module composition — 4 October 2026
+2
View File
@@ -668,6 +668,8 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP
| Recruits / classic Homeworlds | Publisher E3 pp11/16 gives low Fremen4 doubled but capped7. Under choose-recommended, compose original separate-request native population observations: current rate at acceptance, retained accepted group price/free classification, then later requests use changed population and recorded free usage. Bulk7 versus split1+5 is provisional timing, not a new FAQ/user answer. Fremen still cannot buy a seventh without Tleilaxu; typed native deposits and original special-force caps remain. Prior paid/ready returns retain payment and opportunity while phase4 stays open. Clean classic2–6 Basic/Advanced only; Reinforcements uses its separate native fee contract and other roster/overlay/public gates stay. | [Current-rate scope and proof](RECRUITS_RUNTIME.md#classic-homeworld-composition--16-october-2026) |
| Reinforcements / classic Homeworlds | Under standing choose-recommended, seal exact native normal-first sources for the existing three-reserve/all-outcome fee. Ambiguous omission rejects; a unique source may derive. Native reserves on an own World are also its army, so that fee cannot fund the same physical dial/casualties; an off-World fee leaves the pool. Keep original pre-fee traits/printed face, including explosion loss choices across a threshold, then typed Tanks, Harass return and original casualties. This is explicit provisional physical-pool composition, not a new FAQ or own-World ban. Source-aware full/complying plan reviews and named independent-card conditions use canonical names and actual combat locations. Other rosters/overlays/public starts remain gated. | [Native sources and exact proof](REINFORCEMENTS_RUNTIME.md#classic-homeworld-reserve-fees--16-october-2026) |
| Reinforcements / clean selected Ix and CHOAM | Admit original independent cards with clean selected native/base Basic/Advanced rosters and no optional modules/overlays. Reuse existing provisional normal-first/all-outcome payment, original No-Field materialization before the fee, then successful Harass return; physical Stone ignores +2. Native cyborg substitution uses its own casualty origins, not a duplicate reserve fee. This closes admission consistency, not a new numerical ruling or public expansion gate. | [Profile and bounded proof](REINFORCEMENTS_RUNTIME.md#clean-selected-ixchoam-families--16-october-2026) |
| Independent Treachery / all14 Skills | Printed E3 faction-independent variant permission composes fresh classic, selected E1/E2 native/base, or standalone Ecaz OR Moritani/classic all14 Skills, Basic/Advanced2–6, without other modules. Original decks36/38/50, native setup and skill/card custody stay. E3 Advanced Harkonnen, shared-Duke, native-pair/mixed-family Skills and other module envelopes remain guarded. Bare no-Skills card rosters retain every selected known family even without Ecaz seated; this does not widen E3 World/phase modules or change provisional fee/return timing. | [Native physical sources](ECAZ_TREACHERY_RULES.md#native-e1e2-leader-skills-composition--17-october-2026), [standalone E3](ECAZ_TREACHERY_RULES.md#standalone-e3-leader-skills-composition--17-october-2026) |
| 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 E3 variant independence permits unused Tech3–6 Basic/Advanced and/or Strongholds2–6 Advanced with clean classic/selected Ix/CHOAM native/base rosters, including original Homeworlds. Original setup, delayed income and earned custody stay. R fee-before-Harass is provisional; returns are not free Revival/shipment and +2 is not supported force. Native exact fees, current rates/unlimited allowances, marker funding and aftercare retain their separate contracts. Skills, Nexus, Discovery, E3 mixtures and unrelated previews remain gated. | [Phase composition](ECAZ_TREACHERY_RULES.md#original-phase-module-composition--16-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) |
+38 -30
View File
@@ -8581,14 +8581,18 @@ function moritaniAssassinateModeSupported(g: Game) {
return g.advanced && !!g.discoveries &&
(discoveryModeSupported(g) || nativeDiscoveryLeaderSkillsProfile(g)) &&
g.players.some(player => player.faction === 'moritani');
return g.advanced && g.expansions.length === 1 && g.expansions[0] === 'ecaz' &&
return g.advanced && g.expansions.includes('ecaz') && g.expansions.length <= 3 &&
g.expansions.every((id, index) => (id === 'ix' || id === 'choam' || id === 'ecaz') &&
g.expansions.indexOf(id) === index) &&
g.players.some(p => p.faction === 'moritani') &&
g.players.every(p => p.faction === 'moritani' || (p.faction !== 'harkonnen' && CLASSIC_FACTIONS[p.faction] === true)) &&
g.players.every(p => FACTIONS.some(f => f.id === p.faction &&
(f.expansion === 'base' || g.expansions.includes(f.expansion)))) &&
(!g.nexusCards || (!!g.leaderSkills && standaloneE3NexusLeaderSkillsProfile(g))) &&
(!g.leaderSkills || advancedMoritaniLeaderSkillsProfile(g)) &&
(!g.strongholdCards || e3StrongholdFactionProfile(g) ||
(!!g.leaderSkills && strongholdLeaderSkillsProfile(g))) &&
(!g.homeworlds || (!!g.leaderSkills && homeworldLeaderSkillsProfile(g))) &&
(!g.homeworlds || g.homeworldOccupationPreview === true ||
(!!g.leaderSkills && homeworldLeaderSkillsProfile(g))) &&
!g.discoveryEnabled && !g.discoveries &&
(!g.techTokens || nativeFactionTechProfile(g) || (!!g.leaderSkills && nativeTechLeaderSkillsProfile(g)));
}
@@ -9592,6 +9596,7 @@ export function initializeHomeworldOccupationGameForAudit(state: Game, leaderSki
g.homeworldEcazVictoryPreview = true;
g.ecazOccupyPreview = true;
}
if (moritaniAssassinateModeSupported(g)) initializeMoritaniAssassinateState(g);
return g;
}
/** Shared classic/supported-native Nexus setup; existing composition guards apply. */
@@ -10513,8 +10518,8 @@ export function initializeFactionExpansionsGameForAudit(state: Game): Game {
'The faction prototype excludes other optional modules; Tech Tokens require a supported native three-through-six-seat lobby.');
requireFreshFactionInventory(state);
const initialized = initializeSetupGameForAudit(state, false, false, false, false, false, false, true);
if (initialized.advanced && nativeFactionTechProfile(initialized) && byFaction(initialized, 'moritani'))
initializeMoritaniAssassinateState(initialized);
if (byFaction(initialized, 'ecaz')) initialized.ecazOccupyPreview = true;
if (moritaniAssassinateModeSupported(initialized)) initializeMoritaniAssassinateState(initialized);
return initialized;
}
function ecazOccupyCompositionSupported(g: Game): boolean {
@@ -10541,7 +10546,7 @@ function ecazOccupyCompositionSupported(g: Game): boolean {
g.expansions.every(id => ['ecaz', 'ix', 'choam'].includes(id)) &&
new Set(g.expansions).size === g.expansions.length &&
(g.advanced || (g.expansions.length === 1 && g.expansions[0] === 'ecaz' &&
!g.ecazTreachery && g.players.every(p => p.faction === 'ecaz' ||
g.players.every(p => p.faction === 'ecaz' ||
p.faction === 'moritani' || CLASSIC_FACTIONS[p.faction]))) &&
g.players.length >= 2 && g.players.length <= 6 &&
g.players.some(p => p.faction === 'ecaz') &&
@@ -10557,11 +10562,12 @@ function ecazOccupyCompositionSupported(g: Game): boolean {
/** Fresh native Occupy profile; Basic odd counts use the main printed rule provisionally. */
export function initializeEcazOccupyGameForAudit(state: Game): Game {
requireRule(ecazOccupyCompositionSupported(state),
'Occupy requires supported native factions and their distinct selected decks; Basic uses Ecaz/classic/optional Moritani without optional modules.');
'Occupy requires supported native factions and their selected decks; Basic uses Ecaz/classic/optional Moritani and may include the independent cards.');
requireFreshBaseRuntime(state);
requireFreshFactionInventory(state);
const g = initializeSetupGameForAudit(state, false, false, false, false, false, false, true, state.ecazTreachery === true);
g.ecazOccupyPreview = true;
if (moritaniAssassinateModeSupported(g)) initializeMoritaniAssassinateState(g);
return g;
}
/** Original E1/E2 Stronghold roster; shared by fresh setup and Semuta validation. */
@@ -10611,14 +10617,13 @@ function initializeMoritaniAssassinateState(g: Game): void {
g.moritaniAssassinate = {version:1,owner:byFaction(g,'moritani')!.id,normalTraitorCall:false,opportunities:[]};
g.moritaniAssassinateCallEvents = [];
}
/** Genuine, explicitly opted-in preview; no player action enables this profile. */
/** Focused fresh native Moritani entry through the same printed-power setup. */
export function initializeMoritaniAssassinateGameForAudit(state: Game): Game {
requireRule(!state.moritaniAssassinatePreview && !state.moritaniAssassinate && !state.moritaniAssassinateResume && !state.moritaniAssassinateCallEvents,
'The assassination preview starts only from a fresh lobby.');
requireRule(moritaniAssassinateModeSupported(state), 'The assassination preview needs Advanced Moritani with ordinary base opponents except Harkonnen, the Ecaz expansion and no optional modules.');
const g = initializeFactionExpansionsGameForAudit(state);
initializeMoritaniAssassinateState(g);
return g;
requireRule(moritaniAssassinateModeSupported(state),
'Assassination requires Advanced Moritani in a supported selected native roster and module composition.');
return initializeFactionExpansionsGameForAudit(state);
}
/** Development-only Semuta with original native modules/skills, separate bounded
* Homeworld occupation, or Basic Moritani/E3. Public starts stay gated. */
@@ -10703,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 classic or selected Ix/CHOAM lobby without other modules.');
requireRule(independent, 'Independent Treachery with Leader Skills requires a fresh classic, selected Ix/CHOAM or standalone Ecaz/Moritani lobby without other modules.');
requireFreshBaseRuntime(state);
requireFreshFactionInventory(state);
}
@@ -10733,19 +10738,19 @@ export function initializeLeaderSkillsGameForAudit(state: Game): Game {
if ((nativeEcazLeaderSkillsProfile(initialized) || mixedE3 || homeworlds || nativeDiscoveryLeaderSkillsProfile(initialized) || pairedE3) && byFaction(initialized, 'ecaz')) initialized.ecazOccupyPreview = true;
return initialized;
}
/** Independent cards keep the actual selected base/E1/E2 physical families. */
/** Independent cards keep every selected native family's physical counters. */
function independentCardRoster(g: Game): boolean {
if (g.expansions.length > 2 ||
(g.expansions.length === 2 && g.expansions[0] === g.expansions[1]) ||
g.expansions.some(expansion => expansion !== 'ix' && expansion !== 'choam')) return false;
if (g.expansions.length > 3 || g.expansions.some((expansion, index) =>
(expansion !== 'ix' && expansion !== 'choam' && expansion !== 'ecaz') ||
g.expansions.indexOf(expansion) !== index)) return false;
return g.players.every(player => {
const family = faction(player.faction).expansion;
return family === 'base' || ((family === 'ix' || family === 'choam') && g.expansions.includes(family));
return family === 'base' || g.expansions.includes(family);
});
}
function ecazHomeworldProfile(g: Game): boolean {
return !!g.homeworlds && g.ecazTreachery === true && independentCardRoster(g) &&
g.players.length >= 2 && g.players.length <= 6 &&
!g.expansions.includes('ecaz') && 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 &&
@@ -10756,7 +10761,7 @@ function ecazHomeworldProfile(g: Game): boolean {
}
/** Independent cards compose original phase modules, not unrelated previews. */
function independentCardModulesProfile(g: Game): boolean {
if (!g.ecazTreachery || (!g.techTokens && !g.strongholdCards) || !independentCardRoster(g) ||
if (!g.ecazTreachery || (!g.techTokens && !g.strongholdCards) || !independentCardRoster(g) || g.expansions.includes('ecaz') ||
g.players.length < (g.techTokens ? 3 : 2) || g.players.length > 6 ||
(g.strongholdCards && !g.advanced) ||
g.nexusCards || g.leaderSkills ||
@@ -10775,7 +10780,10 @@ export function initializeEcazTreacheryGameForAudit(state: Game): Game {
const homeworlds = !!g.homeworlds;
requireRule(!homeworlds || ecazHomeworldProfile(g),
'Independent cards with Homeworlds need a fresh classic or selected Ix/CHOAM roster with only original Tech or Advanced Strongholds.');
return initializeSetupGameForAudit(g, homeworlds, false, false, false, false, false, true, true);
const initialized = initializeSetupGameForAudit(g, homeworlds, false, false, false, false, false, true, true);
if (byFaction(initialized, 'ecaz')) initialized.ecazOccupyPreview = true;
if (moritaniAssassinateModeSupported(initialized)) initializeMoritaniAssassinateState(initialized);
return initialized;
}
function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = false, ix = false, discovery = false, leaderSkills = false, choam = false, factions = false, ecazTreachery = false, strongholdFactions = false): Game {
nexusCardsIntegrity(state);
@@ -12836,7 +12844,7 @@ function createMentatQuestion(g: Game) {
const event = crypto.randomUUID();
const receipt: MentatQuestionReceipt = {event,battle:b.event!,turn:g.turn,territory:b.territory,
owner,target,leader:trained.leader,stage:'name',weapon:null,card:null,
hand:mentatRule(() => mentatHand(getPlayer(g,target).hand,g.expansions)),signature:''};
hand:mentatRule(() => mentatHand(getPlayer(g,target).hand,g.expansions,g.ecazTreachery === true)),signature:''};
if (!receipt.hand.length) {receipt.stage = 'unavailable';log(g,MENTAT_EMPTY_HAND,{faction:getPlayer(g,owner).faction,name:'Mentat'});}
receipt.signature = mentatSignature(receipt);
b.mentatQuestion = receipt;
@@ -12857,7 +12865,7 @@ function mentatQuestionIntegrity(g: Game) {
g.players.some(p => p.id === observation.owner && p.leaders.some(l => l.id === observation.leader)) &&
mentatWeaponNames(g.expansions).includes(observation.weapon) && observation.signature === mentatSignature(observation),
'The saved Mentat observation changed its question, audience or event.');
mentatRule(() => mentatHand([observation.card],g.expansions));
mentatRule(() => mentatHand([observation.card],g.expansions,g.ecazTreachery === true));
}
const b = g.battle, receipt = b?.mentatQuestion;
const decisions = homeworldSavedDecisions(g).filter(d => d.kind === 'mentatQuestion');
@@ -12874,7 +12882,7 @@ function mentatQuestionIntegrity(g: Game) {
[b.attacker,b.defender].includes(receipt.owner) && [b.attacker,b.defender].includes(receipt.target) && receipt.owner !== receipt.target &&
receipt.signature === mentatSignature(receipt),
'The saved Mentat question changed its battle, audience or original event.');
mentatRule(() => mentatHand(receipt.hand,g.expansions));
mentatRule(() => mentatHand(receipt.hand,g.expansions,g.ecazTreachery === true));
requireRule(['name','reveal','answered','declined','unavailable'].includes(receipt.stage), 'The saved Mentat question has an invalid stage.');
requireRule(receipt.stage === 'unavailable' ? receipt.hand.length === 0 : receipt.hand.length > 0,
'The saved Mentat opportunity changed its empty-hand boundary.');
@@ -12882,7 +12890,7 @@ function mentatQuestionIntegrity(g: Game) {
requireRule(receipt.weapon === null && receipt.card === null, 'The unasked Mentat question cannot contain an answer.');
else {
requireRule(typeof receipt.weapon === 'string', 'The saved Mentat weapon question is missing.');
const quote = mentatRule(() => quoteMentatReveal(receipt.hand,receipt.weapon!,g.expansions));
const quote = mentatRule(() => quoteMentatReveal(receipt.hand,receipt.weapon!,g.expansions,g.ecazTreachery === true));
requireRule(receipt.stage === 'reveal' ? receipt.card === null :
receipt.card && quote.cards.some(card => JSON.stringify(card) === JSON.stringify(receipt.card)),
'The saved Mentat disclosure does not answer its exact original question.');
@@ -12919,7 +12927,7 @@ function projectedMentat(g: Game, viewer: string): MentatView {
player:pending.stage === 'name' ? pending.owner : pending.target,stage:pending.stage as 'name'|'reveal',weapon:pending.weapon,
weapons:pending.stage === 'name' && viewer === pending.owner && pending.hand.length ? mentatWeaponNames(g.expansions) : [],
cards:pending.stage === 'reveal' && viewer === pending.target ?
mentatRule(() => quoteMentatReveal(pending.hand,pending.weapon!,g.expansions)).cards : [],
mentatRule(() => quoteMentatReveal(pending.hand,pending.weapon!,g.expansions,g.ecazTreachery === true)).cards : [],
blocked:pending.stage === 'name' && !pending.hand.length ? MENTAT_EMPTY_HAND : null} : null,
history:(g.mentatHistory ?? []).filter(observation => [observation.owner,observation.target].includes(viewer))
.map(({signature: _signature,...observation}) => structuredClone(observation))};
@@ -12935,7 +12943,7 @@ function actMentatQuestion(g: Game, decision: Extract<Decision,{kind:'mentatQues
} else {
requireRule(Object.keys(action).every(key => ['type','event','weapon'].includes(key)), 'Name only one specific Mentat weapon.');
const weapon = stringField(action.weapon);
mentatRule(() => quoteMentatReveal(receipt.hand,weapon,g.expansions));
mentatRule(() => quoteMentatReveal(receipt.hand,weapon,g.expansions,g.ecazTreachery === true));
receipt.weapon = weapon;
log(g,`${getPlayer(g,receipt.owner).name} asked ${getPlayer(g,receipt.target).name} about ${weapon} with Mentat. The card shown is private and need not be played.`);
// Every question uses the same target-owned response, so public timing
@@ -12944,7 +12952,7 @@ function actMentatQuestion(g: Game, decision: Extract<Decision,{kind:'mentatQues
}
} else {
requireRule(Object.keys(action).every(key => ['type','event','card'].includes(key)), 'Show exactly one held Treachery Card for Mentat.');
const quote = mentatRule(() => quoteMentatReveal(receipt.hand,receipt.weapon!,g.expansions));
const quote = mentatRule(() => quoteMentatReveal(receipt.hand,receipt.weapon!,g.expansions,g.ecazTreachery === true));
const card = quote.cards.find(card => card.id === action.card);
requireRule(card, 'Choose a held card that answers this Mentat question.');
receipt.card = {...card}; receipt.stage = 'answered';
@@ -13845,7 +13853,7 @@ function combinedCardBattleBlock(g: Game): string | null {
(battle.attacker === ecaz.id || battle.defender === ecaz.id ||
battle.attacker === ally.id || battle.defender === ally.id) &&
fighterCount(ecaz, battle.territory) > 0 && fighterCount(ally, battle.territory) > 0)
return 'Combined card armies require the supported Advanced Occupy battle profile.';
return 'Combined card armies require the supported native Occupy battle profile.';
return null;
}
function reinforcementsModeSupported(g: Game) {
@@ -13937,7 +13945,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, classic all14 Skills without other modules, or the existing native Homeworld and phase-module compositions.';
return 'Recruits requires a supported independent-card profile: faction-only, supported all14 Skills without other modules, or the existing native Homeworld and phase-module compositions.';
if (g.status !== 'playing' || g.phase !== 4)
return 'Play Recruits during Revival.';
if (g.recruits?.turn === g.turn)
+13 -5
View File
@@ -241,8 +241,8 @@ function nativeLeaderSkillModulesSupported(game: LeaderSkillProfile): boolean {
/** Standalone E3 native predicates retain their own roster, Duke and assassination limits. */
function e3LeaderSkillModulesSupported(game: LeaderSkillProfile): boolean {
if (game.homeworlds) return homeworldLeaderSkillModulesSupported(game);
if (noOtherLeaderSkillModules(game) || standaloneE3NexusLeaderSkillsProfile(game) ||
pairedE3NexusLeaderSkillsProfile(game)) return true;
if (noOtherLeaderSkillModules(game) || independentTreacheryLeaderSkillsProfile(game) ||
standaloneE3NexusLeaderSkillsProfile(game) || pairedE3NexusLeaderSkillsProfile(game)) return true;
if (game.discoveryEnabled || game.discoveries) return discoveryLeaderSkillModulesSupported(game);
const players = game.players;
if ((!game.techTokens && !game.strongholdCards) || !players ||
@@ -508,32 +508,40 @@ export function homeworldLeaderSkillsProfile(game: LeaderSkillProfile): boolean
return true;
}
/** Independent utility cards with original classic or selected E1/E2 all14 setup. */
/** Independent cards with classic, selected E1/E2 or standalone 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 ||
typeof game.advanced !== 'boolean' || !game.players ||
game.players.length < 2 || game.players.length > 6 ||
game.homeworlds || game.nexusCards || game.discoveries || game.discoveryEnabled ||
game.discoveryStash || game.greatMaker || game.techTokens || game.strongholdCards ||
game.semutaPreview || game.moritaniAssassinatePreview || game.advancedPreview ||
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 (game.expansions[i] !== 'ix' && game.expansions[i] !== 'choam') return false;
if (!e3 && game.expansions[i] !== 'ix' && game.expansions[i] !== 'choam') return false;
for (let j = 0; j < i; j++) if (game.expansions[j] === game.expansions[i]) return false;
}
const players = game.players;
let e3Natives = 0;
for (let i = 0; i < players.length; i++) {
for (let j = 0; j < i; j++) if (players[j].faction === players[i].faction) return false;
let selected = false;
for (const faction of FACTIONS)
if (faction.id === players[i].faction) {
selected = faction.expansion === 'base' || game.expansions.includes(faction.expansion);
if (faction.expansion === 'ecaz') e3Natives++;
break;
}
if (!selected) return false;
}
// Preserve the original standalone native Skills/Duke roster boundaries.
if (e3 && (e3Natives > 1 || (e3Natives > 0 && 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;
}
+8 -4
View File
@@ -1,5 +1,6 @@
import { treacheryDeck, type Card } from './cards';
import { richeseCards } from './richese-cards';
import { ecazTreacheryCards } from './ecaz-cards';
import { quoteTupileIntelligenceAnswer } from './tupile-intelligence-answer';
import { ordinaryLeaderSkillModeSupported, type LeaderSkillProfile } from './leader-skill-profile';
@@ -42,17 +43,18 @@ export type MentatView = {
export function mentatQuestionModeSupported(game: LeaderSkillProfile): boolean {
return ordinaryLeaderSkillModeSupported(game);
}
function inventory(expansions: readonly string[]): Card[] {
function inventory(expansions: readonly string[], ecazTreachery: boolean): Card[] {
return [
...treacheryDeck(expansions),
...(expansions.includes('choam') ? richeseCards() : []),
...(ecazTreachery ? ecazTreacheryCards() : []),
];
}
/** Printed/default held-card roles, shared with Tupile; never battle-slot legality. */
export function mentatWeaponNames(expansions: readonly string[]): string[] {
return [
...new Set(
inventory(expansions)
inventory(expansions, false)
.filter(
(card) =>
quoteTupileIntelligenceAnswer([card], 0, 'weapons').count === 1,
@@ -64,9 +66,10 @@ export function mentatWeaponNames(expansions: readonly string[]): string[] {
export function mentatHand(
hand: readonly Card[],
expansions: readonly string[],
ecazTreachery: boolean,
): Card[] {
const canonical = new Map(
inventory(expansions).map((card) => [card.id, card]),
inventory(expansions, ecazTreachery).map((card) => [card.id, card]),
);
if (
!Array.isArray(hand) ||
@@ -87,10 +90,11 @@ export function quoteMentatReveal(
hand: readonly Card[],
weapon: string,
expansions: readonly string[],
ecazTreachery: boolean,
): { cards: Card[] } {
if (!mentatWeaponNames(expansions).includes(weapon))
throw new MentatQuestionError('Name one specific canonical weapon.');
const held = mentatHand(hand, expansions);
const held = mentatHand(hand, expansions, ecazTreachery);
if (!held.length) throw new MentatQuestionError(MENTAT_EMPTY_HAND);
const matching = held.filter((card) => card.name === weapon);
return {
+6 -6
View File
@@ -1407,8 +1407,8 @@ export const RULE_TOPICS: RuleTopic[] = [
'A native Richese ally may supply ordinary counters, a concealed No-Field, or both under the selected Advanced provisional additive pool. The selected actor gets its private variable pool/mixed notice; rivals do not. Richese materializes once after both seals, including zero/caps. Ordinary dial Prescience follows the actual force owner; distinct Advanced special Karama may privately inspect its complete sealed plan under the provisional power distinction. Prevented Ecaz uses only its own pool; neither private read nor prevention reveals the marker early.',
'Stone Burner compares physical undialed counters, not total dial or paid strength: active Occupy contributes floor(Ecaz forces / 2) plus the ally’s native undialed tokens. Canceled Occupy compares only the selected lead’s own pool. Physical Mirror copies use the same comparison and retain their original cleanup. Hidden opposing No-Field preflight uses public possible pools, not its actual denomination; genuinely winner-changing typed allocations remain blocked.',
'With the independent variant, Harass returns only the actual card user’s undialed forces. An Ecaz user keeps the original mandatory ceil contribution committed at free full strength while its floor count returns; ally support is separate. An allied user subtracts fixed strength before choosing its native typed commitment. Canceled Occupy uses its selected lead’s own pool. Returned reserves are outside later casualties/explosion/Face Dance.',
'Reinforcements consumes three card-user reserves normal-first and adds +2 normal score, never support or physical arrivals. Under the provisional native order, an own No-Field materializes before that original reserve fee and successful Harass returns; three reserves must remain after reveal before this plan may seal. Original/copied Stone ignores +2 score but not the fee. Existing all-revealed-outcome cost/disposal remains provisional. Basic/unprofiled co-side cards stay guarded.',
'Advanced numbers come from the user-authorized revised PDF, physical pages 32 and 19, not a new publisher erratum. Basic follows the main printed paragraph provisionally. Original faction/deck prerequisites, separate ten-card Richese cache and independent three-card variant selection stay. Ordinary-plus-marker Advanced Occupy extends the provisional additive interpretation, not the unresolved FAQ cap. Native Ecaz/classic Skills and supported World previews now retain ordinary Arrakis Occupy; Basic mixed-co-side cards, unsupported optional overlays and full-mode acceptance remain separate.',
'Reinforcements consumes three card-user reserves normal-first and adds +2 normal score, never support or physical arrivals. Under the provisional native order, an own No-Field materializes before that original reserve fee and successful Harass returns; three reserves must remain after reveal before this plan may seal. Original/copied Stone ignores +2 score but not the fee. Existing all-revealed-outcome cost/disposal remains provisional. Unsupported co-side profiles stay guarded.',
'Advanced numbers come from the user-authorized revised PDF, physical pages 32 and 19, not a new publisher erratum. Basic follows the main printed paragraph provisionally. Original faction/deck prerequisites, separate ten-card Richese cache and independent three-card variant selection stay. Ordinary-plus-marker Advanced Occupy extends the provisional additive interpretation, not the unresolved FAQ cap. Fresh plain/card Ecaz and standalone Ecaz/classic Skills retain ordinary Arrakis Occupy, including independent cards without other modules. Basic mixed-co-side markers, unsupported overlays and full-mode acceptance remain separate.',
],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Combined-side enumeration, lead choice, native Occupy response and owner-labelled physical outcomes are connected. Basic odd counts now follow the main printed paragraph provisionally; Advanced retains its authorized arithmetic. Optional/exotic composition refinement remains.', evidence: ['game/ecaz-occupy-battle.ts', 'game/board-resolution-quote.ts', 'game/battle-resolution-quote.ts', 'game/engine.ts'] },
@@ -3038,7 +3038,7 @@ export const RULE_TOPICS: RuleTopic[] = [
summary:
'Six hidden tokens threaten stronghold entrants. Bounded Atomics revelation is available in classic-plus-Moritani tables; complete Moritani starts remain unavailable.',
steps: [
'Placement and isolated entry decisions have controls for Robbery, Sabotage, Sneak Attack, Extortion, ordinary native-leader Assassination and bounded Atomics. You may instead leave a token hidden. Arrivals with unsupported competing reactions remain gated; Moritani starts remain disabled.',
'Placement and isolated entry decisions have controls for Robbery, Sabotage, Sneak Attack, Extortion, ordinary native-leader Assassination and bounded Atomics. You may instead leave a token hidden. Supported native Moritani starts use labelled previews; unsupported competing entry reactions remain guarded.',
'Begin with all six Terror tokens in your hidden supply. Once during Mentat Pause, you may either place one from supply or move one already placed to another eligible stronghold. There is no spice cost.',
'The destination must have no Terror token. A stronghold under storm is eligible, but the Hidden Mobile Stronghold and Homeworlds are excluded.',
'When another faction ships or moves into the marked stronghold, you may reveal its token and apply the effect to that entrant. Your ally does not trigger this opportunity. Bene Gesserit advisors do.',
@@ -3228,7 +3228,7 @@ export const RULE_TOPICS: RuleTopic[] = [
coverage: 'Partial',
developmentStage: 'Prototyped',
summary:
'An opted-in Advanced Moritani preview reveals a different opposing-faction Traitor Card after a loss and replaces it during Mentat Pause.',
'Supported native Advanced Moritani reveals a different opposing-faction Traitor Card after a loss and replaces it during Mentat Pause.',
steps: [
'After you lose a battle, this advanced advantage is available only if the opposing leader disc survived and no Traitor was called.',
'You may reveal a Traitor Card for that opposing faction, but it must name a different leader from the one you just fought. If that named leader is not in the Tanks, kill it and collect spice for its value.',
@@ -3236,7 +3236,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'A Mentat replacement is an ordinary physical Traitor Card, including the original Ix Hero identity when present. After original owned initialization, retire the victim permanently. The replacement itself may later be legally exchanged, returned and drawn again; keep its old receipt unchanged and count one live card, not one per historical reference.',
'The authorized revised Advanced PDF, physical page35, requires that you have not revealed your Traitor Card normally this game. A normal own-card reveal therefore forfeits Assassinate Leaders for the rest of this game, across turns and opponents. Existing state preserves that forfeiture; Karama has no effect against the ability. This is the authorized source, not a new GF9 erratum.',
'The surviving actual played disc need not be native to its victorious controller. Native Ecaz/Duke6, Tleilaxu/foreign Guild5, CHOAM/Auditor2 and Harkonnen captives use the winner’s printed-faction targets. Captivity or Ghola control never rewrites native identity. Auditor has a matching Traitor outside CHOAM’s ordinary five; it can be a different native target for two but not the disc just played. Duke has no Traitor, and Cheap Hero is not a physical opposing disc. Exceptional captured/Ghola targets remain separate.',
'Terror Assassination chooses a random leader when its token is triggered. It does not use this battle-loss condition, different-leader Traitor Card or once-per-faction allowance. Moritani starts remain unavailable in Basic and Advanced.',
'Terror Assassination chooses a random leader when its token is triggered. It does not use this battle-loss condition, different-leader Traitor Card or once-per-faction allowance. Fresh supported plain/card Advanced Moritani starts initialize this printed advantage automatically; Basic never gains it. Independent cards also compose standalone Moritani/classic all14 Skills without other modules, retaining the Advanced Harkonnen and E3 pair/mixed Skills guards. Played saves do not gain invented prior-call history.',
],
checklist: [
{area:'Implementation',status:'Partial',detail:'Canonical played-disc/native target semantics remain. Moritani replacement accepts physical Hero separately; permanent victim cards join the shared removed census, while historical replacement IDs may move/recur legally. Exchange, Face Dancer, Betrayal and Rihani consumers bind their own source zone. Current authorization/census, revealed-card reservation and original parent remain.',evidence:['game/moritani-assassinate.ts','game/nexus-traitor-exchange.ts','game/nexus-face-dancers.ts','game/nexus-harkonnen-betrayal.ts','game/rihani-decipherer.ts','game/engine.ts']},
@@ -4931,7 +4931,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'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 base opponents including Harkonnen. Its separate bounded Advanced classic/non-Harkonnen roster now composes the original assassination preview and all fourteen skills: winner Suk rescue and Rihani choice finish first, then an eligible different trained disc may be assassinated with its exact card return and printed bounty. No casualties repeat; original set-aside and one private Mentat replacement remain. See Moritani with Leader Skills for the exact scope.',
'Fresh Advanced profiles compose native Ixians, Tleilaxu, CHOAM and/or Richese with classic opponents and distinct required family decks. All fourteen skills and native starts remain. Auditor stays unskilled; cyborg casualties, original CHOAM support and Richese capped marker revelation remain physical. Foreign revival creates no new skill. 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 may be selected with all fourteen Skills in fresh classic or selected Ix/CHOAM native/base Basic/Advanced games, without other modules. Original36/38/50 ordinary decks, any separate Richese cache, native setup, free-revival exceptions and paid Cyborg prices stay. Native typed fee/withdrawal/Suk outcomes and ordinary Smuggler, Planetologist and Sandmaster capabilities use one shared admission; the card variant does not silently remove those skills. Other faction/module combinations retain their explicit guards.',
'The independent Recruits, Reinforcements and Harass & Withdraw cards may be selected with all fourteen Skills in fresh classic, selected Ix/CHOAM native/base or standalone Ecaz OR Moritani/classic Basic/Advanced games, without other modules. Original36/38/50 decks, Richese cache, native setup, free-revival exceptions and paid Cyborg prices stay. Card fee/withdrawal/Suk and ordinary Smuggler, Planetologist and Sandmaster capabilities share admission. Ecaz retains selected-lead/separate armies; Advanced Moritani keeps skill-first assassination and one Mentat replacement. Advanced Harkonnen, E3 native-pair/mixed Skills and other module guards remain.',
'Fresh three-through-six-seat Basic/Advanced development tables may compose all fourteen Skills with original Tech Tokens: classic factions, supported E1/E2 native families, or standalone Ecaz/Moritani with the exact ecaz deck and original roster exclusions. Starting cards/cache, skill/Traitor choices and printed/first-Storm assignment stay genuine. Existing leader-skills/banker-income entry preserves unused selected tokens; mixed E3 families, other overlays, public activation and saved-game conversion remain separate.',
'Fresh Advanced classic or supported native two-through-six-seat skill tables may retain six original Stronghold Cards, with Tech at three or more seats. Original end-Mentat/local held effects remain. Arrakeen changes actual payer cost, not rescue or Banker eligibility; native CHOAM income retains bank support. Physical defenses, normal disciplines, holder ties and original dial/Worthless earnings remain. Standalone Ecaz Duke/Arrakis coalition and Moritani assassination compose original rules; mixed E3, exceptional copied defenses, active skill/Stone and other guards stay separate.',
'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.',
@@ -0,0 +1,244 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { Game } from '../game/engine';
import { treacheryDeck } from '../game/cards';
import { ecazTreacheryCards } from '../game/ecaz-cards';
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import {
assertEcazIndependentSkillsCustody,
createEcazIndependentSkillsFixture, ecazIndependentSkillsPlayer as player,
quoteEcazIndependentSkillsBattle, settleEcazIndependentSkillsBattle,
} from './fixture-ecaz-independent-skills';
const at = (game: Game, id: string, key: string) => player(game, id).forces[key] ?? 0;
const change = (before: number, after: number) => after - before;
const cards = (game: Game) => [...game.deck, ...game.discard, ...(game.ixSetupCards ?? []),
...(game.auction?.cards.slice(game.auction.index) ?? []), ...game.players.flatMap(seat => seat.hand)];
/** The admitted physical source is the selected original families exactly once. */
function physicalSource(game: Game, count: number): void {
const source = cards(game);
assert.equal(source.length, count);
assert.deepEqual(source.map(card => card.id).sort(), [...treacheryDeck(game.expansions),
...(game.ecazTreachery ? ecazTreacheryCards() : [])].map(card => card.id).sort());
}
for (const advanced of [false, true]) {
void test(`original independent cards with all14 ${advanced ? 'Advanced' : 'Basic'} reach the printed lead choice, and the trained Ecaz Suk rescues only its own committed losses`, () => {
const f = createEcazIndependentSkillsFixture({ advanced, lead: 'ecaz' });
physicalSource(f.initial, 36);
physicalSource(f.game, 36);
assert.equal(f.beforeLead.decision?.kind, 'ecazBattleLead');
assert.deepEqual(f.beforeLead.decision?.kind === 'ecazBattleLead' ? f.beforeLead.decision.choices
: [], [f.ecaz, f.ally]);
assert.equal(player(f.beforeLead, f.ecaz).ally, f.ally);
assert.equal(player(f.beforeLead, f.ally).ally, f.ecaz);
assert.equal(f.trainer, 'ecaz-0');
assert.equal(f.game.leaderSkills!.assignments.find(a => a.owner === f.ecaz)!.skill, 'suk-graduate');
assert.equal(f.game.phase, 6);
// Both printed cards occupy either slot but stay in the lead's own plan.
const plan = f.plans[0].action;
assert.ok(plan.type === 'battlePlan');
assert.equal(plan.weapon, REINFORCEMENTS_CARD);
assert.equal(plan.defense, HARASS_WITHDRAW_CARD);
assert.equal(f.fixedEcazDial, 2, 'ceil(E/2) of Ecaz’s four committed counters is free');
assert.equal(f.planDial, f.fixedEcazDial + 2, 'the plan dial adds the ally’s two variable counters');
const quote = quoteEcazIndependentSkillsBattle(f);
assert.deepEqual(quote.reinforcements,
[{ player: f.ecaz, card: REINFORCEMENTS_CARD, normal: 3, elite: 0 }]);
assert.deepEqual(quote.harassWithdraw![0].returned, { normal: 2, elite: 0 });
assert.equal(quote.harassWithdraw![0].remaining.normal, 2);
assert.equal(quote.harassWithdraw![0].remaining.elite, 0);
assert.equal(quote.harassWithdraw![0].remaining.freeSupport, true,
'the mandatory fixed Ecaz counters are fully supported by the coalition plan');
assert.deepEqual(quote.harassWithdraw![0].locations, { [f.location]: { normal: 2, elite: 0 } });
assert.deepEqual(quote.fixedLosses, [{ owner: f.ecaz, normal: 2, elite: 0 }]);
assert.equal(quote.casualties?.owner, f.ally);
assert.deepEqual(quote.sukGraduate, { leader: 'ecaz-0', mode: 'skilled' });
assert.ok(quote.scores);
assert.equal(quote.scores[quote.winner === f.game.battle!.attacker ? 'attacker' : 'defender'],
player(f.game, f.ecaz).leaders.find(leader => leader.id === 'ecaz-0')!.strength +
f.planDial + 2, 'the card’s +2 stays a separate score modifier');
// The real rescue offer is the skilled band of Ecaz's own committed losses.
const rescue = settleEcazIndependentSkillsBattle(f, { stop: 'rescue' });
assert.equal(rescue.decision?.kind, 'sukRescue');
if (rescue.decision?.kind !== 'sukRescue') throw Error('Expected the original Suk rescue window');
assert.equal(rescue.decision.mode, 'skilled');
assert.equal(rescue.decision.player, f.ecaz);
assert.deepEqual(rescue.decision.options.filter(option => option.kept),
[{ normal: 1, elite: 0, kept: { key: f.location, kind: 'normal' } },
{ normal: 2, elite: 0, kept: { key: f.location, kind: 'normal' } }]);
const end = settleEcazIndependentSkillsBattle(f, { state: rescue });
assert.equal(end.phase, 7, 'the original battle continues to native Spice Collection');
assert.equal(end.lastBattleContext?.winner, f.ecaz);
assert.equal(end.lastBattleContext?.result, 'normal');
assert.equal(end.lastBattleContext?.sukRescue?.completed, true);
assert.equal(end.pendingSukRescue, null);
// Ecaz: three fee counters in the Tanks, two returned undialed, one rescued
// counter kept in the sector and one back in reserves.
assert.equal(change(player(f.game, f.ecaz).reserves, player(end, f.ecaz).reserves), 0);
assert.equal(change(player(f.game, f.ecaz).tanks, player(end, f.ecaz).tanks), 3);
assert.equal(at(f.game, f.ecaz, f.location), 4);
assert.equal(at(end, f.ecaz, f.location), 1);
assert.equal(at(end, f.ecaz, 'imperial_basin:9'), at(f.game, f.ecaz, 'imperial_basin:9'));
// The ally's own variable army is lossy and never rescued by Ecaz's skill.
assert.equal(change(player(f.game, f.ally).reserves, player(end, f.ally).reserves), 0);
assert.equal(change(player(f.game, f.ally).tanks, player(end, f.ally).tanks), 2);
assert.equal(at(end, f.ally, f.location), 2);
// The defeated defender loses its whole army and never touches the fee.
assert.equal(change(player(f.game, f.opponent).tanks, player(end, f.opponent).tanks), 2);
assert.equal(at(end, f.opponent, f.location), 0);
assert.equal(change(player(f.game, f.opponent).reserves, player(end, f.opponent).reserves), 0);
assertEcazIndependentSkillsCustody(end);
});
}
for (const card of ['reinforcements', 'harass'] as const) {
void test(`${card === 'reinforcements' ? 'Reinforcements alone pays three own reserves before the rescue' : 'Harass & Withdraw alone returns only the card user’s undialed counters'}, never the fixed Ecaz contribution`, () => {
const f = createEcazIndependentSkillsFixture({ cards: [card] });
physicalSource(f.game, 36);
const quote = quoteEcazIndependentSkillsBattle(f);
if (card === 'reinforcements') {
assert.deepEqual(quote.reinforcements,
[{ player: f.ecaz, card: REINFORCEMENTS_CARD, normal: 3, elite: 0 }]);
assert.equal(quote.harassWithdraw, undefined);
} else {
assert.equal(quote.reinforcements, undefined);
assert.deepEqual(quote.harassWithdraw![0].returned, { normal: 2, elite: 0 });
assert.equal(quote.harassWithdraw![0].remaining.freeSupport, true);
}
const end = settleEcazIndependentSkillsBattle(f);
assert.equal(end.phase, 7);
assert.equal(end.lastBattleContext?.winner, f.ecaz);
assert.equal(change(player(f.game, f.ecaz).tanks, player(end, f.ecaz).tanks),
card === 'reinforcements' ? 3 : 0, 'only the Reinforcements fee reaches the Tanks');
assert.equal(change(player(f.game, f.ecaz).reserves, player(end, f.ecaz).reserves),
card === 'reinforcements' ? -2 : 3);
assert.equal(at(end, f.ecaz, f.location), card === 'reinforcements' ? 3 : 1);
assert.equal(change(player(f.game, f.ally).tanks, player(end, f.ally).tanks), 2);
assert.equal(at(end, f.ally, f.location), 2);
assertEcazIndependentSkillsCustody(end);
});
}
void test('an ally-led coalition spends the ally’s own fee and army and never rescues Ecaz’s fixed commitment', () => {
const f = createEcazIndependentSkillsFixture({ lead: 'ally' });
physicalSource(f.game, 36);
assert.equal(f.plans[0].actor, f.ally);
const quote = quoteEcazIndependentSkillsBattle(f);
assert.deepEqual(quote.reinforcements,
[{ player: f.ally, card: REINFORCEMENTS_CARD, normal: 3, elite: 0 }],
'the card user pays the printed reserve cost, not the coalition partner');
assert.deepEqual(quote.harassWithdraw![0].returned, { normal: 0, elite: 2 });
assert.deepEqual(quote.harassWithdraw![0].locations, { [f.location]: { normal: 0, elite: 2 } });
assert.deepEqual(quote.fixedLosses, [{ owner: f.ecaz, normal: 2, elite: 0 }]);
assert.equal(quote.sukGraduate, undefined, 'the trained Ecaz disc is not the winning plan’s leader');
const end = settleEcazIndependentSkillsBattle(f);
assert.equal(end.phase, 7);
assert.equal(end.lastBattleContext?.winner, f.ally);
assert.equal(end.lastBattleContext?.sukRescue, undefined);
assert.equal(f.trainer, 'ecaz-0');
assert.equal(player(end, f.ecaz).leaders.find(leader => leader.id === f.trainer)!.dead, false);
// Ecaz's fixed commitment dies unrescued while its own card custody is untouched.
assert.equal(change(player(f.game, f.ecaz).reserves, player(end, f.ecaz).reserves), 0);
assert.equal(change(player(f.game, f.ecaz).tanks, player(end, f.ecaz).tanks), 2);
assert.equal(at(f.game, f.ecaz, f.location), 4);
assert.equal(at(end, f.ecaz, f.location), 2);
// The ally pays three reserve fee counters plus its own two commitment losses.
assert.equal(change(player(f.game, f.ally).reserves, player(end, f.ally).reserves), -1);
assert.equal(change(player(f.game, f.ally).tanks, player(end, f.ally).tanks), 5);
assert.equal(at(end, f.ally, f.location), 0);
assert.equal(change(player(f.game, f.opponent).tanks, player(end, f.opponent).tanks), 2);
assertEcazIndependentSkillsCustody(end);
});
void test('a revealed skill keeps the skilled leader out of the plan and rescues exactly one committed counter with no kept counter', () => {
const f = createEcazIndependentSkillsFixture({ hide: false });
physicalSource(f.game, 36);
const plan = f.plans[0];
assert.ok(plan.action.type === 'battlePlan' && plan.action.leader !== f.trainer,
'the face-up skilled leader cannot be in the battle plan');
const quote = quoteEcazIndependentSkillsBattle(f);
assert.deepEqual(quote.sukGraduate, { leader: 'ecaz-0', mode: 'normal' });
const end = settleEcazIndependentSkillsBattle(f);
assert.equal(end.phase, 7);
assert.equal(end.pendingSukRescue, null, 'the single normal-band option settles automatically');
assert.equal(end.lastBattleContext?.sukRescue?.completed, true);
// Three fee counters plus the one unrescued committed counter reach the Tanks;
// the normal band never keeps a counter in the battle sector.
assert.equal(change(player(f.game, f.ecaz).tanks, player(end, f.ecaz).tanks), 4);
assert.equal(change(player(f.game, f.ecaz).reserves, player(end, f.ecaz).reserves), 0);
assert.equal(at(end, f.ecaz, f.location), 0);
assert.equal(change(player(f.game, f.ally).tanks, player(end, f.ally).tanks), 2);
assertEcazIndependentSkillsCustody(end);
});
void test('an opposing successful Traitor cancels Harass but preserves the card-user reserve fee', () => {
const f = createEcazIndependentSkillsFixture({ traitor: true, advanced: true });
assert.equal(f.traitor, f.trainer);
assert.ok(player(f.game, f.opponent).traitors.includes(f.traitor!));
const end = settleEcazIndependentSkillsBattle(f);
assert.equal(end.phase, 7);
assert.equal(end.lastBattleContext?.result, 'traitor');
assert.equal(end.lastBattleContext?.winner, f.opponent);
assert.equal(end.lastBattleContext?.sukRescue, undefined);
assert.equal(change(player(f.game, f.ecaz).reserves, player(end, f.ecaz).reserves), -3);
assert.equal(change(player(f.game, f.ecaz).tanks, player(end, f.ecaz).tanks), 7);
assert.equal(at(end, f.ecaz, f.location), 0);
assert.equal(change(player(f.game, f.ally).tanks, player(end, f.ally).tanks), 4);
assert.equal(at(end, f.ally, f.location), 0);
assert.equal(at(end, f.opponent, f.location), 2);
assertEcazIndependentSkillsCustody(end);
});
for (const advanced of [false, true]) {
void test(`the printed ${advanced ? 'Advanced' : 'Basic'} Occupy needs no Skills: the no-Skills independent control settles the same real coalition with both cards`, () => {
const f = createEcazIndependentSkillsFixture({ advanced, skills: false });
assert.equal(f.trainer, null);
assert.equal(f.game.leaderSkills, undefined);
physicalSource(f.game, 36);
assert.equal(f.beforeLead.decision?.kind, 'ecazBattleLead');
const quote = quoteEcazIndependentSkillsBattle(f);
assert.deepEqual(quote.reinforcements,
[{ player: f.ecaz, card: REINFORCEMENTS_CARD, normal: 3, elite: 0 }]);
assert.deepEqual(quote.harassWithdraw![0].returned, { normal: 2, elite: 0 });
assert.equal(quote.sukGraduate, undefined);
const end = settleEcazIndependentSkillsBattle(f);
assert.equal(end.phase, 7);
assert.equal(end.lastBattleContext?.winner, f.ecaz);
assert.equal(end.lastBattleContext?.sukRescue, undefined);
// Fee first, then the return, then the mandatory commitment losses.
assert.equal(change(player(f.game, f.ecaz).tanks, player(end, f.ecaz).tanks), 5);
assert.equal(change(player(f.game, f.ecaz).reserves, player(end, f.ecaz).reserves), -1);
assert.equal(at(end, f.ecaz, f.location), 0);
assert.equal(change(player(f.game, f.ally).tanks, player(end, f.ally).tanks), 2);
assert.equal(at(end, f.ally, f.location), 2);
assert.equal(change(player(f.game, f.opponent).tanks, player(end, f.opponent).tanks), 2);
assertEcazIndependentSkillsCustody(end);
});
}
for (const advanced of [false, true]) {
void test(`the real no-card, no-Skills ${advanced ? 'Advanced' : 'Basic'} coalition still exposes its lead choice and settles to Collection`, () => {
const f = createEcazIndependentSkillsFixture({ advanced, skills: false, independent: false, cards: [] });
assert.equal(f.initial.ecazTreachery, undefined);
assert.equal(f.game.leaderSkills, undefined);
assert.deepEqual(f.cardIds, []);
physicalSource(f.initial, 33);
physicalSource(f.game, 33);
assert.equal(f.beforeLead.decision?.kind, 'ecazBattleLead');
const end = settleEcazIndependentSkillsBattle(f);
assert.equal(end.phase, 7);
assert.equal(end.lastBattleContext?.winner, f.ecaz);
assert.equal(end.lastBattleContext?.result, 'normal');
assert.equal(change(player(f.game, f.ecaz).tanks, player(end, f.ecaz).tanks), 2);
assert.equal(change(player(f.game, f.ecaz).reserves, player(end, f.ecaz).reserves), 0);
assert.equal(at(end, f.ecaz, f.location), 2);
assert.equal(change(player(f.game, f.ally).tanks, player(end, f.ally).tanks), 2);
assert.equal(at(end, f.ally, f.location), 2);
assert.equal(change(player(f.game, f.opponent).tanks, player(end, f.opponent).tanks), 2);
assert.equal(at(end, f.opponent, f.location), 0);
assertEcazIndependentSkillsCustody(end);
});
}
-17
View File
@@ -13,7 +13,6 @@ import {
ecazOccupyCardsCase, finishOccupyCardsAftermath, occupyCardsPlayer as seat,
type OccupyCardsCase,
} from './fixture-ecaz-occupy-cards';
import { harassWithdrawGame, takeHarassCard } from './fixture-harass-withdraw';
const forces = (result: OccupyCardsCase, id: string, game = result.game) =>
ecazOccupyTerritoryForces(game, id, result.fixture.territory);
@@ -361,19 +360,3 @@ void test('selected typed Harass losses survive an opposing mandatory Reinforcem
collection(result.game);
});
void test('Basic co-present Ecaz card armies remain unsupported instead of admitting an ordinary single-army outcome', () => {
for (const card of ['ecaz-harass-withdraw', 'ecaz-reinforcements']) {
const game = harassWithdrawGame({ advanced: false, factions: ['guild', 'emperor', 'ecaz'] });
const guild = game.players[0], ecaz = game.players[2];
// Labelled co-side rule-unit staging on the genuine ordinary variant setup.
guild.ally = ecaz.id;
ecaz.ally = guild.id;
ecaz.forces = { 'arrakeen:10': 2 };
ecaz.reserves -= 2;
takeHarassCard(game, guild.id, card);
assert.throws(() => applyAction(game, guild.id,
{ type: 'battlePlan', dial: 1, leader: 'guild-0', weapon: card }),
/supported Advanced Occupy battle profile/);
assert.equal(game.battle?.plans[guild.id], undefined);
}
});
+432
View File
@@ -0,0 +1,432 @@
import assert from 'node:assert/strict';
import { applyAction, handLimit, viewGame, type Action, type Game, type Player } from '../game/engine';
import { treacheryDeck } from '../game/cards';
import { ecazTreacheryCards } from '../game/ecaz-cards';
import { withoutEcazLoyalty } from '../game/ecaz-loyalty';
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
import { validateLeaderSkills } from '../game/leader-skills';
import { traitorDeck } from '../game/traitors';
import {
defaultHarassWithdrawAllocation, HARASS_WITHDRAW_CARD,
type HarassWithdrawSelection,
} from '../game/harass-withdraw';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import type { SukRescueOption } from '../game/suk-graduate';
import { quoteBattleResolution, type ResolutionCombatant } from '../game/battle-resolution-quote';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
import { chooseEcazOccupyLead, finishEcazOccupySetup, stageEcazOccupyCard } from './fixture-ecaz-occupy';
import { createHomeworldEcazOccupyFixture } from './fixture-homeworld-ecaz-occupy';
/** Printed E3 shared rule (docs/ECAZ_TREACHERY_RULES.md): the three-card variant
* is independent of faction selection and other variants, so a standalone Ecaz
* seat with classic opponents keeps it; all14 Leader Skills is the separate
* optional module documented in docs/LEADER_SKILLS_RULES.md. Card timings and the
* fee-before-return order stay provisional as recorded there, not new rulings. */
export type EcazIndependentSkillsStep = { actor: string; action: Action };
export type EcazIndependentSkillsCard = 'reinforcements' | 'harass';
export type EcazIndependentSkillsOptions = {
advanced?: boolean;
/** All14 skills; Basic and Advanced are both genuine native bands. */
skills?: boolean;
/** The independent physical three-card variant; `false` is the printed no-card control. */
independent?: boolean;
/** Real native Battle Plan cards in the lead's own plan. */
cards?: readonly EcazIndependentSkillsCard[];
lead?: 'ecaz' | 'ally';
/** Trained posture: hide the Ecaz skill for the skilled band, reveal for normal. */
hide?: boolean;
/** The opponent holds the real Traitor matching the committed lead disc and calls it. */
traitor?: boolean;
};
export type EcazIndependentSkillsPolicy = { hide?: Record<string, boolean> };
export const ECAZ_INDEPENDENT_SKILLS_CARD_IDS = {
reinforcements: REINFORCEMENTS_CARD, harass: HARASS_WITHDRAW_CARD,
} as const;
/** Reinforcements occupies the weapon slot, Harass & Withdraw the defense slot. */
export const ECAZ_INDEPENDENT_SKILLS_SLOTS = {
reinforcements: 'weapon', harass: 'defense',
} as const;
export const ecazIndependentSkillsPlayer = (game: Game, actor: string): Player => {
const player = game.players.find(p => p.id === actor);
assert.ok(player, `Missing original seat ${actor}`);
return player;
};
export const ecazIndependentSkillsClean = (game: Game) =>
!game.response && !game.phaseOpening && !game.decision && !game.pendingTreacheryDiscard;
/** Scoped deterministic entropy for this bounded programme, following the
* existing independent-skills fixtures: the fourteen-card lottery is solved with
* real Fisher-Yates draws, later single-word draws in the same creation keep the
* fixed maximum value, larger native requests are forwarded, and the patch is
* restored even when a native action rejects. */
function entropy<T>(run: () => T, rolls: readonly number[]): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const native = crypto.getRandomValues.bind(crypto);
let cursor = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1)
array[0] = cursor < rolls.length ? rolls[cursor++] : 0xffffffff;
else Reflect.apply(native, crypto, [array]);
return array;
};
try { return run(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** Actual Fisher-Yates draws for one desired fourteen-card order. */
function permutationDraws<T>(source: readonly T[], desired: readonly T[]): number[] {
const working = [...source], draws: number[] = [];
for (let index = working.length - 1; index > 0; index--) {
const from = working.indexOf(desired[index]);
assert.ok(from >= 0 && from <= index);
draws.push(Math.floor(((from + 0.5) / (index + 1)) * 4294967296));
[working[index], working[from]] = [working[from], working[index]];
}
assert.deepEqual(working, desired);
return draws;
}
export function stepEcazIndependentSkills(game: Game, step: EcazIndependentSkillsStep): Game {
return applyAction(game, step.actor, step.action);
}
/** Real native decisions only; a human plan, withdrawal allocation or rescue
* choice is never invented by this helper. */
export function nextEcazIndependentSkillsStep(game: Game,
policy: EcazIndependentSkillsPolicy = {}): EcazIndependentSkillsStep | null {
if (!game.response && !game.phaseOpening) {
const decision = game.decision;
if (decision?.kind === 'leaderSkillVisibility')
return { actor: decision.player, action: { type: 'leaderSkillVisibility', event: decision.event,
hide: policy.hide?.[decision.player] ?? true } };
if (decision?.kind === 'guildTiming')
return { actor: decision.player, action: { type: 'decision', take: false } };
if (decision?.kind === 'mentatQuestion')
return { actor: decision.player, action: { type: 'decision', event: decision.event, decline: true } };
if (decision?.kind === 'rihani')
return { actor: decision.player, action: { type: 'decision', event: decision.event, draw: false } };
if (decision?.kind === 'faceDance')
return { actor: decision.player, action: { type: 'decision', reveal: false } };
if (['sukRescue', 'harassWithdraw', 'battleLosses'].includes(decision?.kind ?? '')) return null;
}
return nextStrongholdFactionsNativeStep(game);
}
export function advanceEcazIndependentSkills(state: Game, until: (game: Game) => boolean,
actions: EcazIndependentSkillsStep[] = [], policy: EcazIndependentSkillsPolicy = {}): Game {
let game = state;
for (let count = 0; count < 1500; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const step = nextEcazIndependentSkillsStep(game, policy);
assert.ok(step, 'Supply the genuine private plan, typed return or rescue choice');
actions.push(structuredClone(step));
game = stepEcazIndependentSkills(game, step);
}
throw Error('The native Ecaz independent programme did not reach its boundary');
}
/** Only conserved physical card custody moves; no stock, forces or wallet. */
export function stageEcazIndependentSkillsCard(game: Game, actor: string,
id: string, staging: string[]): string {
const staged = stageEcazOccupyCard(game, actor, card => card.id === id);
staging.push(`Conserved ${staged}: original deck/discard/hand → ${actor} hand; not a purchase, native deal or a new physical card`);
return staged;
}
/** The admitted original card source (thirty-six with the independent variant),
* all14 skills and ordinary counters. */
export function assertEcazIndependentSkillsCustody(game: Game): void {
if (game.leaderSkills) validateLeaderSkills(game.leaderSkills, game.players);
const sum = (values: Record<string, number>) => Object.values(values).reduce((a, b) => a + b, 0);
for (const player of game.players) {
assert.equal(player.reserves + player.tanks + sum(player.forces), 20, player.faction);
assert.ok([player.reserves, player.tanks, ...Object.values(player.forces)]
.every(count => Number.isSafeInteger(count) && count >= 0), player.faction);
if (player.elites) {
assert.equal(player.elites.reserves + player.elites.tanks + sum(player.elites.forces),
player.faction === 'emperor' ? 5 : 3, player.faction);
assert.ok([player.elites.reserves, player.elites.tanks, ...Object.values(player.elites.forces)]
.every(count => Number.isSafeInteger(count) && count >= 0), player.faction);
assert.ok(player.elites.reserves <= player.reserves && player.elites.tanks <= player.tanks);
for (const [key, count] of Object.entries(player.elites.forces))
assert.ok(count <= (player.forces[key] ?? 0));
}
assert.ok(player.hand.length <= handLimit(player), player.faction);
}
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
...(game.ixSetupCards ?? []), ...(game.auction?.cards.slice(game.auction.index) ?? [])];
assert.deepEqual(cards.map(card => card.id).sort(), [...treacheryDeck(game.expansions),
...(game.ecazTreachery ? ecazTreacheryCards() : [])].map(card => card.id).sort());
assert.deepEqual([...(game.traitorReserve ?? []), ...game.players.flatMap(p => p.traitors),
...game.players.flatMap(p => (p.faceDancers ?? []).map(dancer => dancer.leader))].sort(),
withoutEcazLoyalty(traitorDeck(game.players, game.expansions.includes('ix')), game.ecazLoyalty).sort());
}
export type EcazIndependentSkillsFixture = {
/** Original fresh lobby/setup snapshots taken by the shared genuine programme. */
initial: Game; afterSetup: Game;
/** Original before-lead window and the recorded native lead choice. */
beforeLead: Game; leadStep: EcazIndependentSkillsStep; afterLead: Game;
/** Clean before-plan window (both plans still unsealed) and the real plans. */
beforePlans: Game; plans: EcazIndependentSkillsStep[];
/** Revealed window: both original plans sealed, postures committed. */
game: Game;
/** Typed physical allocation derived from the real projected offer. */
returns: HarassWithdrawSelection;
ecaz: string; ally: string; opponent: string; territory: string; location: string;
fixedEcazDial: number; planDial: number; support: number;
cardUser: string; cardIds: string[]; cardKinds: EcazIndependentSkillsCard[];
/** Ecaz's real trained disc; null when the no-Skills control is used. */
trainer: string | null;
/** Conserved opposing Traitor identity and its actual caller seats. */
traitor: string | null; callers: string[];
advanced: boolean; skills: boolean; independent: boolean; hide: boolean;
actions: EcazIndependentSkillsStep[]; staging: string[];
};
/** Original offered card and eligible disc for every seat; Ecaz requests its
* trained Suk Graduate. The captured `initial` snapshot stays untouched. */
function trainEcazSeat(initial: Game, steps: EcazIndependentSkillsStep[]): Game {
let game = initial;
while (game.status === 'setup' && game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0];
const offer = game.leaderSkills!.offers[actor];
const view = viewGame(game, actor).leaderSkills!;
const skill: LeaderSkillId = actor === 'ecaz' ? 'suk-graduate'
: offer.cards.find(card => !view.unavailableSkills?.[card])!;
assert.ok(offer.cards.includes(skill), 'The requested original skill must be in the real offer');
assert.ok(!view.unavailableSkills?.[skill], 'The requested original skill must be selectable');
const eligible = new Set(view.eligibleLeaders.map(leader => leader.id));
const leader = ecazIndependentSkillsPlayer(game, actor).leaders
.filter(disc => eligible.has(disc.id)).sort((a, b) => b.strength - a.strength)[0];
assert.ok(leader, 'The real eligible leaders must supply this seat’s choice');
const step: EcazIndependentSkillsStep = { actor,
action: { type: 'leaderSkill', event: offer.event, skill, leader: leader.id } };
steps.push(structuredClone(step));
game = stepEcazIndependentSkills(game, step);
}
return finishEcazOccupySetup(game);
}
/** Conserved original Traitor identity: one real held card exchanges with the
* opponent's reserve copy so the printed call has genuine custody. */
function conserveTraitor(game: Game, actor: string, identity: string, staging: string[]): void {
const player = ecazIndependentSkillsPlayer(game, actor);
if (player.traitors.includes(identity)) return;
const returned = player.traitors[0];
assert.ok(returned, 'The opponent needs an original Traitor Card to exchange');
const reserve = game.traitorReserve?.indexOf(identity) ?? -1;
const donor = game.players.find(seat => seat.id !== actor && seat.traitors.includes(identity));
assert.ok(reserve >= 0 || donor, 'The requested Traitor must retain original custody');
if (reserve >= 0) game.traitorReserve!.splice(reserve, 1, returned);
else donor!.traitors[donor!.traitors.indexOf(identity)] = returned;
player.traitors[0] = identity;
staging.push(`Conserved Traitor ${identity}: original ${donor?.id ?? 'reserve'} custody exchanged with ${actor}'s ${returned}; no new Traitor Card`);
}
/** Real living disc for a sealed plan; a revealed skilled leader has to stay
* behind the shield, so it is never offered here. */
function battleLeader(game: Game, seat: string, exclude: string | null, strongest: boolean): string {
const discs = ecazIndependentSkillsPlayer(game, seat).leaders
.filter(disc => !disc.dead && !disc.usedAt && disc.id !== exclude);
assert.ok(discs.length, `The ${seat} roster must supply a legal living leader`);
return discs.sort((a, b) => (strongest ? b.strength - a.strength : a.strength - b.strength) ||
a.id.localeCompare(b.id))[0].id;
}
/** Genuine native setup, a real worm Nexus alliance, paid Arrakis shipments and
* the original lead/plan windows. With skills the all14 lottery is solved with
* real Fisher-Yates draws so Ecaz’s own offer holds its Suk Graduate, while the
* remaining single-word draws in this one creation keep the fixed maximum value
* and larger native requests are forwarded unchanged. */
export function createEcazIndependentSkillsFixture(
options: EcazIndependentSkillsOptions = {},
): EcazIndependentSkillsFixture {
const advanced = options.advanced ?? true;
const skills = options.skills ?? true;
const independent = options.independent ?? true;
const hide = options.hide ?? true;
const lead = options.lead ?? 'ecaz';
const cardKinds = [...(options.cards ?? ['reinforcements', 'harass'] as const)];
const picks: EcazIndependentSkillsStep[] = [];
const staging: string[] = [];
const order = ['suk-graduate' as LeaderSkillId,
...LEADER_SKILL_CARDS.map(card => card.id).filter(id => id !== 'suk-graduate')];
const draws = skills ? permutationDraws(LEADER_SKILL_CARDS.map(card => card.id), order) : [];
const shared = entropy(() => createHomeworldEcazOccupyFixture({
advanced, skills, independent, homeworlds: false,
...(skills ? { finishSetup: (initial: Game) => trainEcazSeat(initial, picks) } : {}),
}), draws);
if (skills) {
staging.push('Solved the original fourteen-card lottery with real Fisher-Yates draws for Ecaz’s own offer; later single-word draws in this creation kept the fixed maximum value and larger native requests were forwarded unchanged.');
assert.equal(shared.initial.leaderSkills !== undefined, true);
const ecazAssignment = shared.afterSetup.leaderSkills!.assignments.find(a => a.owner === shared.ecaz);
assert.equal(ecazAssignment?.skill, 'suk-graduate',
'The requested original skill must be the real assignment of that seat');
} else {
assert.equal(shared.initial.leaderSkills, undefined);
assert.equal(shared.afterSetup.leaderSkills, undefined);
}
assert.equal(shared.initial.ecazTreachery === true, independent,
'The independent three-card variant must be an original lobby selection');
const trainer = skills ? shared.afterSetup.leaderSkills!.assignments.find(a => a.owner === shared.ecaz)!.leader : null;
const beforeLead = structuredClone(shared.game);
assert.equal(beforeLead.decision?.kind, 'ecazBattleLead',
'The printed Ecaz Occupy lead choice must be the genuine pending decision');
const leadStep: EcazIndependentSkillsStep = { actor: shared.ecaz, action: { type: 'decision',
event: beforeLead.decision!.event, lead: lead === 'ecaz' ? shared.ecaz : shared.ally } };
const actions: EcazIndependentSkillsStep[] = [...shared.actions, ...picks];
let game = chooseEcazOccupyLead({ ...shared, game: beforeLead }, lead);
actions.push(structuredClone(leadStep));
const policy: EcazIndependentSkillsPolicy = { hide: {
[shared.ecaz]: hide, [shared.ally]: true, [shared.opponent]: true } };
game = advanceEcazIndependentSkills(game,
state => !!state.battle && ecazIndependentSkillsClean(state) && !state.battle.preparation &&
state.battle.preLeader?.closed !== false, actions, policy);
const afterLead = structuredClone(game);
const beforePlans = structuredClone(game);
const cardUser = lead === 'ecaz' ? shared.ecaz : shared.ally;
const cardIds = cardKinds.map(kind => stageEcazIndependentSkillsCard(game, cardUser,
ECAZ_INDEPENDENT_SKILLS_CARD_IDS[kind], staging));
const profile = viewGame(game, cardUser).battle?.ecazOccupy?.profile;
assert.ok(profile, 'The selected coalition must quote its real Occupy profile');
assert.equal(profile.lead, cardUser);
const own: { weapon?: string; defense?: string } = {};
for (const [index, kind] of cardKinds.entries())
own[ECAZ_INDEPENDENT_SKILLS_SLOTS[kind]] = cardIds[index];
const variable = 2;
const support = variable;
const allyTrainer = game.leaderSkills?.assignments.find(a => a.owner === shared.ally)?.leader;
const planLeader = cardUser === shared.ecaz
? (skills && hide ? trainer! : battleLeader(game, shared.ecaz, trainer, true))
: battleLeader(game, shared.ally, allyTrainer ?? null, true);
const defender = battleLeader(game, shared.opponent, null, false);
const traitor = options.traitor ? planLeader : null;
const callers: string[] = [];
if (traitor) {
conserveTraitor(game, shared.opponent, traitor, staging);
callers.push(shared.opponent);
}
const plans: EcazIndependentSkillsStep[] = [
{ actor: cardUser, action: { type: 'battlePlan', dial: profile.fixedEcazDial + variable,
support, allyPayment: 0, leader: planLeader, ...own } },
{ actor: shared.opponent, action: { type: 'battlePlan', dial: 0, support: 0,
leader: defender, weapon: null, defense: null } },
];
for (const plan of plans) {
actions.push(structuredClone(plan));
game = stepEcazIndependentSkills(game, plan);
}
assert.ok(game.battle?.revealed, 'Both original plans must be revealed');
// A sole legal allocation is applied by the engine; otherwise the card user
// still holds a genuine typed sector choice, derived from the real offer.
let returns: HarassWithdrawSelection = {};
if (game.decision?.kind === 'harassWithdraw') {
const offer = viewGame(game, game.decision.player).battle?.harassAllocation;
assert.ok(offer, 'Use only the actual revealed physical allocation offer');
returns = defaultHarassWithdrawAllocation(offer.context, offer.dial, offer.support);
}
const fixture: EcazIndependentSkillsFixture = {
initial: shared.initial, afterSetup: shared.afterSetup, beforeLead, leadStep, afterLead,
beforePlans, plans, game, returns, ecaz: shared.ecaz, ally: shared.ally, opponent: shared.opponent,
territory: shared.territory, location: shared.location, fixedEcazDial: profile.fixedEcazDial,
planDial: profile.fixedEcazDial + variable, support, cardUser, cardIds, cardKinds, trainer,
traitor, callers, advanced, skills, independent, hide, actions, staging,
};
assertEcazIndependentSkillsCustody(game);
return fixture;
}
/** Real revealed quote: the card user's own reserve fee, the coalition scores
* and the typed own-army withdrawal groups, never a merged army. */
export function quoteEcazIndependentSkillsBattle(f: EcazIndependentSkillsFixture, state?: Game) {
const game = state ?? f.game;
const battle = game.battle;
assert.ok(battle?.revealed, 'Quote only the genuinely revealed original battle');
const view = viewGame(game, f.cardUser).battle!;
const seat = (actor: string): ResolutionCombatant => {
const player = ecazIndependentSkillsPlayer(game, actor);
const own = viewGame(game, actor).battle!;
const plan = battle.plans[actor];
assert.ok(plan, `The ${actor} plan must be sealed`);
return { id: actor, faction: player.faction, spice: player.spice, hand: player.hand, plan,
leader: player.leaders.find(leader => leader.id === plan.leader),
forces: own.ownForces!,
leaderSkills: (game.leaderSkills?.assignments ?? []).filter(a => a.owner === actor)
.map(a => ({ skill: a.skill, leader: a.leader,
faceUp: battle.leaderSkillHidden?.[actor] !== true, captured: false })),
...(own.reinforcements ? { reinforcementsReserves: {
normal: player.reserves - (player.elites?.reserves ?? 0), elite: player.elites?.reserves ?? 0 } } : {}),
...(own.harassWithdraw ? { harassWithdraw: { blocked: null, forces: own.harassWithdraw.forces,
locations: own.harassWithdraw.locations,
...(own.harassWithdraw.occupy ? { occupy: own.harassWithdraw.occupy } : {}) },
...(Object.keys(f.returns).length ? { harassSelection: f.returns } : {}) } : {}) };
};
assert.ok(view.ecazOccupy?.profile, 'Quote the actual selected native coalition');
return quoteBattleResolution({
advanced: game.advanced,
turn: game.turn,
territory: battle.territory,
aggressor: viewGame(game, game.host).battle!.aggressor,
ecazOccupy: view.ecazOccupy.profile,
attacker: seat(battle.attacker),
defender: seat(battle.defender),
voters: viewGame(game, game.host).battle!.traitorVoters.map(id => ({ id,
beneficiary: battle.ecazOccupy?.lead && [f.ecaz, f.ally].includes(id)
? battle.ecazOccupy.lead : id,
called: false, traitors: ecazIndependentSkillsPlayer(game, id).traitors })),
participants: game.players,
physicalCards: [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand)],
pendingAuditorPresent: false,
pendingRetentionPresent: false,
});
}
/** Real lead choice for a custom programme; the recorded step stays legal. */
export function chooseEcazIndependentSkillsLead(game: Game, lead: 'ecaz' | 'ally',
ecaz: string, ally: string): EcazIndependentSkillsStep {
const decision = game.decision;
assert.ok(decision?.kind === 'ecazBattleLead', 'Only the native Ecaz lead choice may select this plan owner');
return { actor: ecaz, action: { type: 'decision', event: decision.event, lead: lead === 'ecaz' ? ecaz : ally } };
}
/** Resolve the original aftermath: Traitor calls, the typed withdrawal
* allocation, the real casualty choices, the Suk rescue and mandatory disposal,
* stopping at native Spice Collection. */
export function settleEcazIndependentSkillsBattle(f: EcazIndependentSkillsFixture,
options: { state?: Game; returns?: HarassWithdrawSelection;
rescue?: (options: readonly SukRescueOption[]) => number;
stop?: 'rescue' | 'returns' | 'collection' } = {}): Game {
let game = structuredClone(options.state ?? f.game);
for (let count = 0; count < 400; count++) {
if (options.stop === 'rescue' && game.decision?.kind === 'sukRescue') return game;
if (options.stop === 'returns' && game.decision?.kind === 'harassWithdraw') return game;
if (game.phase === 7 && ecazIndependentSkillsClean(game)) return game;
assert.notEqual(game.status, 'finished');
let step: EcazIndependentSkillsStep | null;
const decision = game.decision;
if (decision?.kind === 'harassWithdraw') {
const returns = options.returns ?? f.returns;
const offer = viewGame(game, decision.player).battle?.harassAllocation;
assert.ok(offer, 'Use only the actual revealed physical allocation offer');
step = { actor: decision.player, action: { type: 'decision', event: decision.event, returns } };
} else if (decision?.kind === 'battleLosses') {
const index = decision.options.findIndex(option => option.normal === f.planDial - f.fixedEcazDial &&
option.elite === 0);
step = { actor: decision.player, action: { type: 'decision',
choice: index >= 0 ? index : 0 } };
} else if (decision?.kind === 'sukRescue') {
const choice = options.rescue ? options.rescue(decision.options)
: decision.options.findIndex(option => option.normal === f.fixedEcazDial && option.elite === 0 && option.kept);
assert.ok(choice >= 0, 'Choose one real Suk Graduate rescue option');
step = { actor: decision.player, action: { type: 'decision', event: decision.event, choice } };
} else {
const next = nextEcazIndependentSkillsStep(game);
step = next && next.action.type === 'traitorCall'
? { ...next, action: { type: 'traitorCall', call: f.callers.includes(next.actor) } } : next;
}
assert.ok(step, 'Resolve both original plans before their physical aftermath');
game = stepEcazIndependentSkills(game, step);
}
throw Error('The native Ecaz independent battle did not reach Collection');
}
+5 -2
View File
@@ -35,9 +35,11 @@ export function nextHomeworldEcazStep(game: Game): HomeworldEcazStep | null {
* Arrakis shipments. Only unopened Spice order is staged; armies, wallets,
* phases, alliances, World custody and offered skills follow the real actions. */
export function createHomeworldEcazOccupyFixture(options: {
advanced?: boolean; skills?: boolean;
advanced?: boolean; skills?: boolean; independent?: boolean;
/** No-World control uses the same genuine alliance/Arrakis route. */
homeworlds?: boolean;
/** Select only actual offered setup choices; the captured initial stays untouched. */
finishSetup?: (initial: Game) => Game;
} = {}): HomeworldEcazOccupyFixture {
const advanced = options.advanced ?? true;
const homeworlds = options.homeworlds ?? true;
@@ -50,11 +52,12 @@ export function createHomeworldEcazOccupyFixture(options: {
game = applyAction(game, actor, action);
};
if (homeworlds) act('ecaz', { type: 'homeworlds', enabled: true });
if (options.independent) act('ecaz', { type: 'moduleOption', module: 'ecazTreachery', enabled: true });
if (options.skills) act('ecaz', { type: 'moduleOption', module: 'leaderSkills', enabled: true });
for (const player of game.players) act(player.id, { type: 'ready' });
act('ecaz', advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
const initial = structuredClone(game);
game = finishEcazOccupySetup(game);
game = (options.finishSetup ?? finishEcazOccupySetup)(game);
const afterSetup = structuredClone(game);
const step = () => {
const next = nextHomeworldEcazStep(game);
@@ -0,0 +1,476 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, handLimit, joinGame, newPlayer, viewGame,
type Action, type Game, type Player,
} from '../game/engine';
import { treacheryDeck, type Card } from '../game/cards';
import { botActions } from '../game/bots';
import type { Difficulty } from '../game/bot-profiles';
import { ecazTreacheryCards } from '../game/ecaz-cards';
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
import { validateLeaderSkills } from '../game/leader-skills';
import { traitorDeck } from '../game/traitors';
import {
HARASS_WITHDRAW_CARD, defaultHarassWithdrawAllocation,
type HarassWithdrawContext, type HarassWithdrawSelection,
} from '../game/harass-withdraw';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import { quoteIndependentSkillsBattle } from './fixture-independent-skills-battle';
import { holdMoritaniStrongholdsTraitor } from './fixture-moritani-strongholds';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
export type MoritaniIndependentSkillsStep = { actor: string; action: Action };
/** 'guild' keeps the classic Heighliner opponent; 'harkonnen' is the native
* no-Skills Advanced roster and exercises its real capture offer. */
export type MoritaniIndependentSkillsOpponent = 'guild' | 'harkonnen';
export type MoritaniIndependentSkillsOptions = {
/** Original rules mode of the fresh lobby built here; never a preview marker. */
rules?: 'basic' | 'advanced';
/** All fourteen physical Leader Skill cards, dealt by the real initializer. */
skills?: boolean;
/** The independent three-card Ecaz Treachery variant of printed p. 18. */
cards?: boolean;
opponent?: MoritaniIndependentSkillsOpponent;
/** Conserved Traitor identity Moritani really holds: the unused target disc
* (default) or the winning committed disc, for a real normal Traitor call. */
traitor?: 'target' | 'winner';
/** Continue only the fresh classic/E3 lobby this fixture itself built. */
initial?: Game;
};
/** Real human controls only: the loss owner's typed withdrawal, every Traitor
* call, the winner's cleanup discard, the Harkonnen capture offer and the
* Assassinate choice are never invented by a fallback. */
export type MoritaniIndependentSkillsPolicy = {
placement?: { territory: string; sector: number };
harassReturns?: HarassWithdrawSelection | ((context: HarassWithdrawContext) => HarassWithdrawSelection);
traitorCalls?: readonly string[];
/** Played cards the winner really discards during the original cleanup. */
winnerDiscard?: 'played' | 'none';
/** Real Harkonnen Advanced choice: decline by default, or capture and then
* keep or execute the captive through the original offer and response. */
capture?: { accept: boolean; captured?: 'keep' | 'execute' };
hide?: Readonly<Record<string, boolean>>;
};
export type MoritaniIndependentSkillsFixture = {
initial: Game;
offered: Game;
afterSetup: Game;
beforeSplit: Game;
split: MoritaniIndependentSkillsStep;
afterSplit: Game;
entry: MoritaniIndependentSkillsStep;
afterEntry: Game;
beforeBattle: Game;
battleStep: MoritaniIndependentSkillsStep;
/** Phase-six window with the real battle chosen and both plans unsealed. */
game: Game;
plans: MoritaniIndependentSkillsStep[];
revealed: Game;
/** The real typed undialed return of the loser's revealed Harass plan. */
returns: HarassWithdrawSelection | null;
returnsContext: HarassWithdrawContext | null;
/** Real post-loss state: the owner's Assassinate choice (Advanced), the
* Harkonnen capture offer, or the clean continuation (Basic). */
pending: Game;
moritani: string;
opponent: string;
third: string;
territory: string;
first: string;
second: string;
/** The opposing leader disc that was really committed and won. */
winnerLeader: string;
/** The losing Moritani disc that was really committed. */
loserLeader: string;
/** Conserved physical Traitor identity held by Moritani for this battle. */
traitorTarget: string;
/** The identity Moritani really holds, which may be the winning disc. */
heldTraitor: string;
chosen: Record<string, LeaderSkillId>;
cards: { reinforcements: string | null; harass: string | null; winnerWeapon: string };
actions: MoritaniIndependentSkillsStep[];
staging: string[];
};
export const moritaniIndependentSkillsPlayer = (game: Game, actor: string): Player => {
const player = game.players.find((seat) => seat.id === actor);
assert.ok(player, `Missing original seat ${actor}`);
return player;
};
export const moritaniIndependentSkillsClean = (game: Game) =>
!game.response && !game.phaseOpening && !game.decision && !game.pendingTreacheryDiscard;
/** Scoped real shuffle entropy, restored even when a native action rejects. */
function entropy<T>(run: () => T, rolls: readonly number[] = []): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const native = crypto.getRandomValues.bind(crypto);
let cursor = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array)
for (let index = 0; index < array.length; index++) array[index] = rolls[cursor++] ?? 0xffffffff;
else Reflect.apply(native, crypto, [array]);
return array;
};
try { return run(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** Only the original fourteen-card lottery is solved; later native draws keep
* a fixed maximum value, and every roll is restored afterwards. */
function skillRolls(chosen: readonly LeaderSkillId[]): number[] {
const source = LEADER_SKILL_CARDS.map((card) => card.id);
const rest = source.filter((id) => !chosen.includes(id));
const desired = chosen.flatMap((id) => [id, rest.shift()!]).concat(rest);
const work = [...source], rolls: number[] = [];
for (let index = work.length - 1; index > 0; index--) {
const from = work.indexOf(desired[index]);
assert.ok(from >= 0 && from <= index);
rolls.push(Math.floor((from + 0.5) * 0x100000000 / (index + 1)));
[work[index], work[from]] = [work[from], work[index]];
}
return rolls;
}
export function stepMoritaniIndependentSkills(game: Game, step: MoritaniIndependentSkillsStep): Game {
return entropy(() => applyAction(game, step.actor, step.action));
}
/** Real private projection and bot policy; never alters a human lobby seat. */
export function moritaniIndependentSkillsPolicy(
game: Game, actor: string, difficulty: Difficulty = 'Easy',
): Action[] {
const view = viewGame(game, actor);
view.players.find((player) => player.id === actor)!.bot = difficulty;
return entropy(() => botActions(view));
}
/** Only genuine native decisions; a human plan, Traitor call, typed return,
* capture response or Assassinate choice is never invented here. */
export function nextMoritaniIndependentSkillsStep(
game: Game, policy: MoritaniIndependentSkillsPolicy = {},
): MoritaniIndependentSkillsStep | null {
if (!game.response && !game.phaseOpening) {
const decision = game.decision;
if (decision?.kind === 'guildTiming')
return { actor: decision.player, action: { type: 'decision', take: false } };
if (decision?.kind === 'moritaniSetup') {
const placement = policy.placement;
assert.ok(placement, 'Supply the real unoccupied Moritani starting territory');
return { actor: decision.player, action: { type: 'decision',
territory: placement.territory, sector: placement.sector } };
}
if (decision?.kind === 'leaderSkillVisibility')
return { actor: decision.player, action: { type: 'leaderSkillVisibility',
event: decision.event, hide: policy.hide?.[decision.player] ?? false } };
if (decision?.kind === 'mentatQuestion')
return { actor: decision.player, action: { type: 'decision', event: decision.event, decline: true } };
if (decision?.kind === 'rihani')
return { actor: decision.player, action: { type: 'decision', event: decision.event, draw: false } };
if (decision?.kind === 'battleLosses')
return { actor: decision.player, action: { type: 'decision', choice: 0 } };
if (decision?.kind === 'battleCards')
return { actor: decision.player, action: { type: 'decision',
discard: policy.winnerDiscard === 'none' ? [] : [...decision.cards] } };
if (decision?.kind === 'captureOffer')
return { actor: decision.player,
action: { type: 'decision', accept: policy.capture?.accept ?? false } };
if (decision?.kind === 'capturedLeader')
return { actor: decision.player,
action: { type: 'decision', mode: policy.capture?.captured ?? 'keep' } };
if (decision?.kind === 'moritaniPlacement')
return { actor: decision.player, action: { type: 'decision', decline: true } };
if (decision?.kind === 'moritaniSpecialKarama')
return { actor: decision.player, action: { type: 'decision', event: decision.event, decline: true } };
if (decision?.kind === 'moritaniAssassinate' || decision?.kind === 'harassWithdraw' ||
decision?.kind === 'sukRescue' || decision?.kind === 'handExchange') return null;
}
return nextStrongholdFactionsNativeStep(game);
}
export function advanceMoritaniIndependentSkills(
state: Game, until: (game: Game) => boolean,
actions: MoritaniIndependentSkillsStep[] = [], policy: MoritaniIndependentSkillsPolicy = {},
): Game {
let game = structuredClone(state);
for (let count = 0; count < 2000; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const step = nextMoritaniIndependentSkillsStep(game, policy);
assert.ok(step, `Supply the original human choice at ${game.phase}/${game.decision?.kind}`);
actions.push(structuredClone(step));
game = stepMoritaniIndependentSkills(game, step);
}
throw Error('The standalone Moritani programme did not reach its requested boundary');
}
const counterTotal = (forces: Record<string, number>) =>
Object.values(forces).reduce((sum, count) => sum + count, 0);
/** Conserved physical custody: counters, the real 33/36-card inventory, all
* fourteen skills and every Traitor identity, including Assassinate removal. */
export function assertMoritaniIndependentSkillsCustody(game: Game): void {
if (game.leaderSkills) validateLeaderSkills(game.leaderSkills, game.players);
const removed = (game.moritaniAssassinate?.opportunities ?? [])
.filter((receipt) => receipt.stage === 'replaced').map((receipt) => receipt.card!);
for (const player of game.players) {
assert.equal(player.reserves + player.tanks + counterTotal(player.forces), 20, player.faction);
assert.ok([player.reserves, player.tanks, ...Object.values(player.forces)]
.every((count) => Number.isSafeInteger(count) && count >= 0), player.faction);
if (player.elites) assert.equal(
player.elites.reserves + player.elites.tanks + counterTotal(player.elites.forces),
player.faction === 'emperor' ? 5 : 3, player.faction);
assert.ok(player.hand.length <= handLimit(player), player.faction);
}
const cards = [...game.deck, ...game.discard, ...game.players.flatMap((player) => player.hand),
...(game.auction?.cards.slice(game.auction.index) ?? [])];
assert.deepEqual(cards.map((card) => card.id).sort(),
[...treacheryDeck(game.expansions), ...(game.ecazTreachery ? ecazTreacheryCards() : [])]
.map((card) => card.id).sort(),
'Every physical Treachery Card must stay in one known zone.');
assert.deepEqual([...(game.traitorReserve ?? []), ...game.players.flatMap((player) => player.traitors), ...removed].sort(),
traitorDeck(game.players).sort(), 'Every physical Traitor Card must stay in one known zone.');
}
/** Conserved physical card custody; no purchase, adoption or minted identity. */
function conserveCard(game: Game, actor: string, match: (card: Card) => boolean, staging: string[]): Card {
const player = moritaniIndependentSkillsPlayer(game, actor);
const held = player.hand.find(match);
if (held) return held;
const zones = [{ label: 'remaining original deck', cards: game.deck },
{ label: 'original discard', cards: game.discard },
...game.players.filter((other) => other.id !== actor).map((other) => ({ label: other.id, cards: other.hand }))];
const source = zones.find((zone) => zone.cards.some(match));
assert.ok(source, 'The required physical card must stay in deck, discard or hand custody');
if (player.hand.length >= handLimit(player)) {
const returned = player.hand.shift()!;
game.deck.push(returned);
staging.push(`Conserved ${returned.id}: ${actor} hand → undealt deck for printed hand capacity`);
}
const card = source.cards.splice(source.cards.findIndex(match), 1)[0];
player.hand.push(card);
staging.push(`Conserved original ${card.id}: ${source.label} → ${actor} hand; not a purchase or native deal`);
return card;
}
function board(opponent: MoritaniIndependentSkillsOpponent) {
return opponent === 'guild'
? { battle: 'pasty_mesa', from: 'tueks_sietch:5', amount: 5, sectors: [5, 6] }
: { battle: 'imperial_basin', from: 'carthag:11', amount: 10, sectors: [11, 10] };
}
/** Genuine normal lobby start (no audit profile seam) for a standalone
* Moritani/classic independent three-card app with optional all-fourteen skills. */
export function createMoritaniIndependentSkillsFixture(
options: MoritaniIndependentSkillsOptions = {},
): MoritaniIndependentSkillsFixture {
const advanced = (options.rules ?? 'advanced') === 'advanced';
const skills = options.skills ?? false;
const cards = options.cards ?? false;
const opponentFaction = options.opponent ?? 'guild';
assert.ok(!skills || opponentFaction === 'guild',
'Leader Skills keep the Harkonnen native roster out of this composition.');
const plan = board(opponentFaction);
const target = opponentFaction === 'guild' ? 'guild-1' : 'harkonnen-1';
const winner = opponentFaction === 'guild' ? 'guild-0' : 'harkonnen-0';
const loser = 'moritani-4';
const heldIdentity = (options.traitor ?? 'target') === 'winner' ? winner : target;
const chosen: Record<string, LeaderSkillId> = {
moritani: 'warmaster', [opponentFaction]: 'killer-medic', emperor: 'master-of-assassins',
};
const trainedLeader: Record<string, string> = {
moritani: 'moritani-0', [opponentFaction]: target, emperor: 'emperor-0',
};
let game: Game = options.initial ? structuredClone(options.initial)
: createGame('MORITANIINDEPENDENTSKILLS', newPlayer('moritani', 'Moritani', 'moritani'), advanced, ['ecaz']);
if (!options.initial) {
joinGame(game, newPlayer(opponentFaction, opponentFaction, opponentFaction));
joinGame(game, newPlayer('emperor', 'Emperor', 'emperor'));
}
assert.equal(game.advanced, advanced);
assert.deepEqual(game.expansions, ['ecaz']);
assert.equal(game.status, 'lobby', 'Continue only a fresh lobby of this exact composition.');
assert.ok(!game.homeworlds && !game.nexusCards && !game.discoveryEnabled &&
!game.techTokens && !game.strongholdCards,
'Only the standalone E3/classic composition without other modules is accepted.');
const actions: MoritaniIndependentSkillsStep[] = [], staging: string[] = [];
const act = (actor: string, action: Action) => {
actions.push({ actor, action: structuredClone(action) });
game = stepMoritaniIndependentSkills(game, { actor, action });
return game;
};
const initial = structuredClone(game);
if (skills) act('moritani', { type: 'moduleOption', module: 'leaderSkills', enabled: true });
if (cards) act('moritani', { type: 'moduleOption', module: 'ecazTreachery', enabled: true });
for (const player of game.players) if (!player.ready) act(player.id, { type: 'ready' });
const start: MoritaniIndependentSkillsStep = { actor: 'moritani',
action: { type: 'start', ...(advanced ? { advancedPreview: true as const } : {}) } };
actions.push(structuredClone(start));
game = entropy(() => applyAction(game, start.actor, start.action),
skills ? skillRolls([chosen.moritani, chosen[opponentFaction], chosen.emperor]) : []);
staging.push('Normal lobby start through the real module options; the Advanced opt-in is the ordinary lobby action, never a development profile.');
if (skills) staging.push('Solved only the original fourteen-card Leader Skill lottery with real Fisher-Yates draws; offers, deck and later native shuffles are untouched.');
const offered = structuredClone(game);
for (let count = 0; game.status === 'setup' && count < 200; count++) {
if (game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0];
const offer = game.leaderSkills!.offers[actor];
const view = viewGame(game, actor).leaderSkills!;
const leader = moritaniIndependentSkillsPlayer(game, actor).leaders
.filter((disc) => disc.id === trainedLeader[actor] &&
view.eligibleLeaders.some((eligible) => eligible.id === disc.id))[0];
assert.ok(leader, 'The requested original leader must be eligible for this offer');
assert.ok(offer.cards.includes(chosen[actor]), 'The requested original skill must be in the real offer');
act(actor, { type: 'leaderSkill', event: offer.event, skill: chosen[actor], leader: leader.id });
continue;
}
if (game.setupStage === 'traitors' || game.players.some((player) => player.traitorChoices.length)) {
const player = game.players.find((seat) => seat.traitorChoices.length)!;
const keep = player.id === 'moritani'
? (player.traitorChoices.find((id) => id.startsWith(`${opponentFaction}-`)) ?? player.traitorChoices[0])
: player.traitorChoices[0];
act(player.id, { type: 'traitor', leader: keep });
continue;
}
if (game.decision?.kind === 'moritaniSetup') {
assert.notEqual(game.storm, plan.sectors[0], 'Moritani’s first setup sector must not open inside the storm.');
act(game.decision.player, { type: 'decision', territory: plan.battle, sector: plan.sectors[0] });
continue;
}
const step = nextStrongholdFactionsNativeStep(game);
assert.ok(step, `The original setup continuation stopped at ${game.setupStage ?? game.decision?.kind}`);
act(step.actor, step.action);
}
assert.equal(game.status, 'playing');
const moritani = 'moritani';
const opponent = game.players.find((seat) => seat.faction === opponentFaction)!.id;
const third = game.players.find((seat) => seat.faction === 'emperor')!.id;
assert.equal(moritaniIndependentSkillsPlayer(game, moritani).reserves, 14);
assert.equal(!!game.moritaniAssassinate, advanced,
'The printed Advanced assassination producer follows the real rules mode alone.');
if (skills) assert.equal(game.leaderSkills!.assignments.find((entry) => entry.owner === opponent)!.leader, target);
holdMoritaniStrongholdsTraitor(game, moritani, heldIdentity, staging);
const afterSetup = structuredClone(game);
game = advanceMoritaniIndependentSkills(game,
(state) => state.phase === 5 && state.active === moritani && moritaniIndependentSkillsClean(state), actions);
const beforeSplit = structuredClone(game);
assert.equal(moritaniIndependentSkillsPlayer(game, moritani).forces[`${plan.battle}:${plan.sectors[0]}`], 6);
const split: MoritaniIndependentSkillsStep = { actor: moritani, action: {
type: 'move', from: `${plan.battle}:${plan.sectors[0]}`, amount: 2,
territory: plan.battle, sector: plan.sectors[1] } };
assert.notEqual(game.storm, plan.sectors[1], 'The real within-territory move must not enter the storm sector.');
game = stepMoritaniIndependentSkills(game, split);
actions.push(structuredClone(split));
game = advanceMoritaniIndependentSkills(game,
(state) => state.phase === 5 && state.active === moritani && moritaniIndependentSkillsClean(state), actions);
assert.deepEqual(moritaniIndependentSkillsPlayer(game, moritani).forces,
{ [`${plan.battle}:${plan.sectors[0]}`]: 4, [`${plan.battle}:${plan.sectors[1]}`]: 2 });
staging.push('Moritani split two real counters into a second sector of the same territory, so the printed Harass & Withdraw return is physically ambiguous.');
const afterSplit = structuredClone(game);
game = advanceMoritaniIndependentSkills(game,
(state) => state.phase === 5 && state.active === opponent && moritaniIndependentSkillsClean(state), actions);
const entrySector = plan.sectors[1];
assert.notEqual(game.storm, entrySector, 'The real opponent entry must not enter the storm sector.');
const entry: MoritaniIndependentSkillsStep = { actor: opponent, action: {
type: 'move', from: plan.from, amount: plan.amount, territory: plan.battle, sector: entrySector } };
game = stepMoritaniIndependentSkills(game, entry);
actions.push(structuredClone(entry));
const afterEntry = structuredClone(game);
game = advanceMoritaniIndependentSkills(game,
(state) => state.phase === 6 && !state.battle && moritaniIndependentSkillsClean(state), actions);
const beforeBattle = structuredClone(game);
const offeredBattle = viewGame(game, game.active!).battleChoices
.find((choice) => choice.territory === plan.battle);
assert.ok(offeredBattle, 'The real co-occupation must produce a native battle choice');
const battleStep: MoritaniIndependentSkillsStep = { actor: offeredBattle.chooser, action: {
type: 'chooseBattle', territory: plan.battle,
target: offeredBattle.attacker === offeredBattle.chooser ? offeredBattle.defender : offeredBattle.attacker } };
game = stepMoritaniIndependentSkills(game, battleStep);
actions.push(structuredClone(battleStep));
game = advanceMoritaniIndependentSkills(game,
(state) => !!state.battle && !state.battle.revealed && moritaniIndependentSkillsClean(state), actions);
const reinforcements = cards
? conserveCard(game, moritani, (card) => card.id === REINFORCEMENTS_CARD, staging) : null;
const harass = cards
? conserveCard(game, moritani, (card) => card.id === HARASS_WITHDRAW_CARD, staging) : null;
const winnerWeapon = conserveCard(game, opponent, (card) => card.kind === 'worthless', staging);
const plans: MoritaniIndependentSkillsStep[] = [
{ actor: moritani, action: { type: 'battlePlan', dial: 2, support: 0, leader: loser,
...(reinforcements && harass ? { weapon: REINFORCEMENTS_CARD, defense: HARASS_WITHDRAW_CARD } : {}) } },
{ actor: opponent, action: { type: 'battlePlan', dial: cards ? 1 : 0, support: 0,
leader: winner, weapon: winnerWeapon.id } },
];
const window = structuredClone(game);
let revealed = structuredClone(game);
for (const plan of plans) {
revealed = stepMoritaniIndependentSkills(revealed, plan);
actions.push(structuredClone(plan));
}
assert.ok(revealed.battle?.revealed);
const footer = (viewGame(revealed, moritani).battle as {
harassAllocation?: { context: HarassWithdrawContext; dial: number; support: number };
} | null)?.harassAllocation ?? null;
assert.ok(!cards || footer, 'The committed Reinforcements fee plus Harass return must be a real physical choice');
const returns = footer ? defaultHarassWithdrawAllocation(footer.context, footer.dial, footer.support) : null;
const fixture: MoritaniIndependentSkillsFixture = {
initial, offered, afterSetup, beforeSplit, split, afterSplit, entry, afterEntry, beforeBattle,
battleStep, game: window, plans, revealed, returns, returnsContext: footer?.context ?? null,
pending: structuredClone(revealed), moritani, opponent, third, territory: plan.battle,
first: `${plan.battle}:${plan.sectors[0]}`, second: `${plan.battle}:${plan.sectors[1]}`,
winnerLeader: winner, loserLeader: loser, traitorTarget: target, heldTraitor: heldIdentity, chosen,
cards: { reinforcements: reinforcements?.id ?? null, harass: harass?.id ?? null, winnerWeapon: winnerWeapon.id },
actions, staging,
};
return { ...fixture, pending: resolveMoritaniIndependentSkillsBattle(fixture, revealed) };
}
/** The real revealed quote of both sealed plans and the typed return. The
* shared quote helper needs the all-fourteen skills composition. */
export function quoteMoritaniIndependentSkillsBattle(
fixture: MoritaniIndependentSkillsFixture, state = fixture.revealed,
) {
assert.ok(state.leaderSkills,
'The shared revealed-battle quote needs the all-fourteen Leader Skills composition.');
return quoteIndependentSkillsBattle(state, fixture.returns ? { returns: fixture.returns } : {});
}
/** Resolve the real aftermath up to the owner's Assassinate choice (Advanced)
* or the clean post-battle continuation (Basic), never past a human choice. */
export function resolveMoritaniIndependentSkillsBattle(
fixture: MoritaniIndependentSkillsFixture, state = fixture.pending,
policy: MoritaniIndependentSkillsPolicy = {},
): Game {
const context = fixture.returnsContext;
const chosenReturns = (typeof policy.harassReturns === 'function' && context
? policy.harassReturns(context)
: policy.harassReturns as HarassWithdrawSelection | undefined) ?? fixture.returns;
let game = structuredClone(state);
for (let count = 0; count < 500; count++) {
if (game.decision?.kind === 'moritaniAssassinate' || game.decision?.kind === 'handExchange') return game;
if ((game.decision?.kind === 'captureOffer' || game.decision?.kind === 'capturedLeader') && !policy.capture)
return game;
if (!game.battle && moritaniIndependentSkillsClean(game) &&
(game.phase === 7 || game.phase === 8)) return game;
assert.notEqual(game.status, 'finished');
let step: MoritaniIndependentSkillsStep | null;
if (game.decision?.kind === 'harassWithdraw') {
assert.ok(chosenReturns, 'Supply the loser’s real typed undialed return');
step = { actor: game.decision.player,
action: { type: 'decision', event: game.decision.event, returns: chosenReturns } };
} else if (game.battle?.revealed && (viewGame(game, game.host).battle?.traitorVoters ?? [])
.some((voter) => game.battle!.traitorCalls[voter] === undefined)) {
const voter = viewGame(game, game.host).battle!.traitorVoters
.find((id) => game.battle!.traitorCalls[id] === undefined)!;
step = { actor: voter,
action: { type: 'traitorCall', call: policy.traitorCalls?.includes(voter) ?? false } };
} else step = nextMoritaniIndependentSkillsStep(game, policy);
assert.ok(step, 'Resolve the real inherited decisions before the owner’s choice');
game = stepMoritaniIndependentSkills(game, step);
}
throw Error('The original standalone battle aftermath did not settle');
}
/** Apply the real owner choice: one held eligible Traitor Card, or null. */
export function chooseMoritaniIndependentSkillsAssassination(state: Game, card: string | null): Game {
const decision = state.decision;
assert.equal(decision?.kind, 'moritaniAssassinate');
if (decision?.kind !== 'moritaniAssassinate') throw new Error('Expected the original Assassinate choice.');
return stepMoritaniIndependentSkills(state, { actor: decision.player, action: {
type: 'decision', event: decision.event,
...(card === null ? { decline: true } : { card }),
} });
}
/** Continue the settled fight through the real Mentat replacement window. */
export function finishMoritaniIndependentSkillsBattle(
state: Game, policy: MoritaniIndependentSkillsPolicy = {},
until: (game: Game) => boolean = (game) => game.phase === 8 && moritaniIndependentSkillsClean(game),
): Game {
return advanceMoritaniIndependentSkills(state, until, [], policy);
}
@@ -14,7 +14,7 @@ export type NativeFamilyReinforcementsStep = { actor: string; action: Action };
export type NativeFamilyReinforcementsOptions = {
faction?: 'ixians' | 'tleilaxu' | 'choam';
advanced?: boolean;
expansions?: ('ix' | 'choam')[];
expansions?: ('ix' | 'choam' | 'ecaz')[];
slot?: 'weapon' | 'defense';
outcome?: 'winner' | 'loser' | 'traitor' | 'explosion';
/** Nine legally paid suboids leave one suboid and four cyborgs in reserve. */
@@ -98,7 +98,7 @@ export function createNativeFamilyReinforcementsFixture(
): NativeFamilyReinforcementsFixture {
const faction = options.faction ?? 'ixians', advanced = options.advanced ?? options.initial?.advanced ?? false;
const expansions = options.expansions ?? options.initial?.expansions ?? [faction === 'choam' ? 'choam' : 'ix'];
assert.ok(expansions.length > 0 && expansions.every(id => id === 'ix' || id === 'choam'));
assert.ok(expansions.length > 0 && expansions.every(id => id === 'ix' || id === 'choam' || id === 'ecaz'));
assert.equal(new Set(expansions).size, expansions.length);
assert.ok(expansions.includes(faction === 'choam' ? 'choam' : 'ix'));
assert.ok(!options.mixedFee || faction === 'ixians');
+6 -2
View File
@@ -3,7 +3,6 @@ import { mock } from 'node:test';
import {
applyAction,
createGame,
initializeLeaderSkillsGameForAudit,
joinGame,
newPlayer,
viewGame,
@@ -31,12 +30,14 @@ export function takeMentatCard(game: Game, player: string, name: string) {
export function mentatQuestionGame({
advanced = false,
choam = false,
independent = false,
cards = ['Crysknife', 'Snooper'],
begin = true,
captured = false,
}: {
advanced?: boolean;
choam?: boolean;
independent?: boolean;
cards?: string[];
begin?: boolean;
captured?: boolean;
@@ -53,6 +54,9 @@ export function mentatQuestionGame({
newPlayer('d', 'Questioned opponent', captured ? 'harkonnen' : 'atreides'),
);
joinGame(game, newPlayer('o', 'Observer', 'guild'));
game = applyAction(game, game.host, { type: 'moduleOption', module: 'leaderSkills', enabled: true });
if (independent)
game = applyAction(game, game.host, { type: 'moduleOption', module: 'ecazTreachery', enabled: true });
for (const player of game.players)
game = applyAction(game, player.id, { type: 'ready' });
const index = LEADER_SKILL_CARDS.findIndex((card) => card.id === 'mentat');
@@ -62,7 +66,7 @@ export function mentatQuestionGame({
return array;
});
try {
game = initializeLeaderSkillsGameForAudit(game);
game = applyAction(game, game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
} finally {
mock.restoreAll();
}
+40 -4
View File
@@ -13,6 +13,7 @@ import {
quoteMentatReveal,
} from '../game/mentat-question';
import { baseDeck, ixDeck } from '../game/cards';
import { ecazTreacheryCards } from '../game/ecaz-cards';
import {
finishMentatPosture,
mentatPlayer,
@@ -100,11 +101,46 @@ void test('Mentat names exact printed/default weapons and validates physical han
]) {
assert.ok(!choam.includes(name));
assert.ok(!ix.includes(name));
assert.throws(() => quoteMentatReveal(baseDeck().slice(0, 1), name, []));
assert.throws(() => quoteMentatReveal(baseDeck().slice(0, 1), name, [], false));
}
assert.throws(() => mentatHand([baseDeck()[0], baseDeck()[0]], []));
assert.throws(() => mentatHand([{ ...baseDeck()[0], name: 'Lasgun' }], []));
assert.throws(() => mentatHand(ixDeck().slice(0, 1), []));
assert.throws(() => mentatHand([baseDeck()[0], baseDeck()[0]], [], false));
assert.throws(() => mentatHand([{ ...baseDeck()[0], name: 'Lasgun' }], [], false));
assert.throws(() => mentatHand(ixDeck().slice(0, 1), [], false));
});
void test('Mentat accepts selected independent Specials as fallback disclosures, never as named weapons or unselected cards', () => {
const specials = ecazTreacheryCards();
const names = mentatWeaponNames(['ecaz']);
for (const card of specials) {
assert.ok(!names.includes(card.name));
assert.deepEqual(quoteMentatReveal([card], 'Lasgun', ['ecaz'], true).cards, [card]);
assert.throws(() => quoteMentatReveal([card], 'Lasgun', ['ecaz'], false));
}
});
for (const hide of [false, true])
void test(`independent-card Mentat disclosure keeps physical custody and resumes ${hide ? 'trained' : 'normal'} posture`, () => {
let game = mentatQuestionGame({ independent: true, cards: ecazTreacheryCards().map(card => card.name) });
const before = resources(game);
game = nameWeapon(game, 'Lasgun');
const pending = viewGame(game, 'd').mentat.pending!;
assert.deepEqual(pending.cards.map(card => card.id), ecazTreacheryCards().map(card => card.id));
assert.deepEqual(viewGame(game, 'a').mentat.pending!.cards, []);
assert.deepEqual(viewGame(game, 'o').mentat.pending!.cards, []);
const shown = pending.cards.find(card => card.effect === 'harassWithdraw')!;
game = applyAction(game, 'd', { type: 'decision', event: pending.event, card: shown.id });
assert.deepEqual(resources(game), before, 'Showing a card neither transfers nor reserves it');
assert.deepEqual(viewGame(game, 'a').mentat.history[0].card, shown);
assert.deepEqual(viewGame(game, 'o').mentat.history, []);
game = finishMentatPosture(game, hide);
assert.equal(game.battle!.leaderSkillHidden!.a, hide);
game = finishPowers(game);
game = applyAction(game, 'a', {
type: 'battlePlan', dial: 0, leader: hide ? 'emperor-0' : 'emperor-1',
});
assert.equal(game.battle!.plans.a.leader, hide ? 'emperor-0' : 'emperor-1');
assert.equal(game.battle!.plans.d, undefined, 'The other combatant still owns its unsealed plan');
assert.ok(mentatPlayer(game, 'd').hand.some(card => card.id === shown.id));
});
void test('fresh supported Leader Skills battles offer a private Mentat question without a separate opt-in', () => {
+1 -9
View File
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {applyAction,viewGame,initializeMoritaniAssassinateGameForAudit,createGame,newPlayer,joinGame,type Game} from '../game/engine';
import {applyAction,viewGame,type Game} from '../game/engine';
import {DIFFICULTIES} from '../game/bot-profiles';
import {assassinationGame,stageAssassinationBattle,prepareAssassinationBattle,resolveAssassinationBattle,assassinationChoice,assassinationToMentat,assassinationPhysical,assassinationRejects,assassinationActions} from './moritani-assassinate-fixture';
import {advanceToNextStorm,finishToMovement} from './fixture-advanced-source';
@@ -122,14 +122,6 @@ void test('saved assassination receipts and cleanup obligations fail closed afte
assert.throws(()=>viewGame(missing,'m'));
});
void test('the explicit preview refuses Basic and exceptional-faction lobbies without mutation',()=>{
for(const [advanced,opponent] of [[false,'guild'],[true,'harkonnen'],[true,'ecaz']] as const){
const g=createGame('ASSAINIT',newPlayer('m','Moritani','moritani'),advanced,['ecaz']);
joinGame(g,newPlayer('x','Opponent',opponent));for(const p of g.players)p.ready=true;
const before=structuredClone(g);assert.throws(()=>initializeMoritaniAssassinateGameForAudit(g));assert.deepEqual(g,before);
}
});
void test('a sealed normal traitor call does not publish the assassination guard before the other voter replies',()=>{
const prepared=prepareAssassinationBattle(stageAssassinationBattle(assassinationGame(),'guild-0'));
const called=applyAction(prepared,'m',{type:'traitorCall',call:true});
@@ -0,0 +1,343 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import {
advanceMoritaniIndependentSkills,
assertMoritaniIndependentSkillsCustody,
chooseMoritaniIndependentSkillsAssassination,
createMoritaniIndependentSkillsFixture,
finishMoritaniIndependentSkillsBattle,
moritaniIndependentSkillsClean,
moritaniIndependentSkillsPlayer,
quoteMoritaniIndependentSkillsBattle,
resolveMoritaniIndependentSkillsBattle,
stepMoritaniIndependentSkills,
type MoritaniIndependentSkillsFixture,
} from './fixture-moritani-independent-skills';
const player = moritaniIndependentSkillsPlayer;
const reject = (game: Game, actor: string, action: Action) => {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
};
const owned = (fixture: MoritaniIndependentSkillsFixture, game: Game, identity: string) =>
player(game, fixture.opponent).leaders.find((leader) => leader.id === identity)!;
const choiceOf = (game: Game, viewer: string) =>
viewGame(game, viewer).moritaniAssassinate!.pending!;
void test('Advanced standalone Moritani keeps the printed Assassinate power through the real lobby, the real loss and one held unused Traitor', () => {
const f = createMoritaniIndependentSkillsFixture({ rules: 'advanced', cards: true, skills: true });
const moritani = player(f.afterSetup, f.moritani), guild = player(f.afterSetup, f.opponent);
// The producer is the ordinary fresh roster, never the separate audit profile.
assert.equal(f.afterSetup.moritaniAssassinatePreview, true);
assert.equal(f.afterSetup.moritaniAssassinate!.owner, f.moritani);
assert.deepEqual(f.afterSetup.moritaniAssassinate!.opportunities, []);
assert.equal(f.afterSetup.moritaniAssassinate!.normalTraitorCall, false);
assert.deepEqual(f.afterSetup.moritaniAssassinateCallEvents, []);
assert.deepEqual(f.afterSetup.expansions, ['ecaz']);
assert.equal(f.afterSetup.ecazTreachery, true);
assert.equal(f.afterSetup.homeworlds, undefined);
assert.equal(f.afterSetup.leaderSkills!.assignments.length, 3);
assert.equal(moritani.spice, 12);
assert.equal(moritani.reserves, 14);
assert.equal(moritani.tanks, 0);
assert.deepEqual(moritani.forces, { [f.first]: 6 });
assert.equal(guild.reserves, 15);
assert.deepEqual(guild.forces, { 'tueks_sietch:5': 5 });
assertMoritaniIndependentSkillsCustody(f.afterSetup);
// The real revealed battle: Moritani's own physical Reinforcements/Harass loss.
const battle = f.revealed.battle!;
assert.equal(battle.territory, f.territory);
assert.equal(battle.plans[f.moritani].weapon, REINFORCEMENTS_CARD);
assert.equal(battle.plans[f.moritani].defense, HARASS_WITHDRAW_CARD);
assert.equal(battle.plans[f.moritani].leader, f.loserLeader);
assert.equal(battle.plans[f.opponent].leader, f.winnerLeader);
assert.equal(f.returnsContext!.locations[f.first].normal, 4);
assert.equal(f.returnsContext!.locations[f.second].normal, 2);
assert.deepEqual(f.returns, { [f.first]: { normal: 2, elite: 0 } });
assert.equal(f.pending.lastBattleContext!.winner, f.opponent);
assert.equal(f.pending.lastBattleContext!.result, 'normal');
assert.equal(f.pending.lastBattleContext!.territory, f.territory);
// Real fee, real casualties and real returns, before the owner's choice.
const held = player(f.pending, f.moritani), rival = player(f.pending, f.opponent);
assert.equal(held.spice, 12);
assert.equal(held.reserves, 13);
assert.equal(held.tanks, 7);
assert.deepEqual(held.forces, {});
assert.equal(rival.spice, 5);
assert.equal(rival.reserves, 15);
assert.equal(rival.tanks, 2);
assert.deepEqual(rival.forces, { [f.second]: 3 });
assert.deepEqual(held.traitors, [f.traitorTarget]);
assert.equal(owned(f, f.pending, f.traitorTarget).dead, false);
assert.equal(owned(f, f.pending, f.winnerLeader).dead, false);
// The owner's real, private choice: one conserved unused Traitor identity.
assert.equal(f.pending.decision?.kind, 'moritaniAssassinate');
assert.equal(f.pending.decision?.player, f.moritani);
const decision = f.pending.decision;
if (decision?.kind !== 'moritaniAssassinate') throw new Error('Expected the original Assassinate choice.');
const event = decision.event;
const choice = choiceOf(f.pending, f.moritani);
assert.equal(choice.opponent, f.opponent);
assert.equal(choice.territory, f.territory);
assert.equal(choice.blocked, null);
assert.deepEqual(choice.cards, [{ card: f.traitorTarget,
name: owned(f, f.pending, f.traitorTarget).name,
bounty: owned(f, f.pending, f.traitorTarget).strength, dead: false }]);
assert.equal(choice.cards.some((card) => card.card === f.winnerLeader), false);
assert.deepEqual(choiceOf(f.pending, f.opponent).cards, []);
assert.deepEqual(choiceOf(f.pending, f.third).cards, []);
reject(f.pending, f.opponent, { type: 'decision', event, card: f.traitorTarget });
reject(f.pending, f.moritani, { type: 'decision', event: 'stale', card: f.traitorTarget });
reject(f.pending, f.moritani, { type: 'decision', event, card: f.winnerLeader });
reject(f.pending, f.moritani, { type: 'decision', event, card: 'guild-2' });
reject(f.pending, f.moritani, { type: 'decision', event, decline: true, card: f.traitorTarget });
const spent = chooseMoritaniIndependentSkillsAssassination(f.pending, f.traitorTarget);
const receipt = spent.moritaniAssassinate!.opportunities.at(-1)!;
const target = owned(f, spent, f.traitorTarget);
assert.equal(target.dead, true);
assert.equal(target.deaths, 1);
assert.equal(target.usedAt, undefined);
assert.equal(receipt.stage, 'revealed');
assert.equal(receipt.card, f.traitorTarget);
assert.equal(receipt.opposingLeader, f.winnerLeader);
assert.equal(receipt.bounty, owned(f, f.pending, f.traitorTarget).strength);
assert.equal(receipt.replacement, null);
assert.equal(player(spent, f.moritani).spice, 12 + receipt.bounty);
assert.equal(player(spent, f.opponent).spice, 5);
// Ordered leader-skill custody: the dead leader's card returns once.
assert.equal(spent.leaderSkills!.deck.filter((id) => id === 'killer-medic').length, 1);
assert.deepEqual(spent.leaderSkills!.assignments, [
{ owner: f.moritani, leader: 'moritani-0', skill: f.chosen.moritani },
{ owner: f.third, leader: 'emperor-0', skill: f.chosen.emperor },
]);
assert.deepEqual(player(spent, f.moritani).traitors, [f.traitorTarget]);
// The original winner-card cleanup is preserved, not erased by the choice.
assert.equal(spent.decision?.kind, 'battleCards');
assert.equal(spent.decision?.player, f.opponent);
assert.equal(player(spent, f.opponent).hand.some((card) => card.id === f.cards.winnerWeapon), true);
assert.equal(spent.discard.filter((card) => card.id === REINFORCEMENTS_CARD).length, 1);
assert.equal(spent.discard.filter((card) => card.id === HARASS_WITHDRAW_CARD).length, 1);
assert.equal(spent.discard.filter((card) => card.id === f.cards.winnerWeapon).length, 0);
assertMoritaniIndependentSkillsCustody(spent);
const settled = finishMoritaniIndependentSkillsBattle(spent, { winnerDiscard: 'played' });
assert.equal(player(settled, f.opponent).hand.some((card) => card.id === f.cards.winnerWeapon), false);
assert.equal(settled.discard.filter((card) => card.id === f.cards.winnerWeapon).length, 1);
const replacement = settled.moritaniAssassinate!.opportunities.at(-1)!;
assert.equal(replacement.stage, 'replaced');
assert.ok(replacement.replacement && replacement.replacement !== f.traitorTarget);
assert.deepEqual(player(settled, f.moritani).traitors, [replacement.replacement]);
assert.equal(settled.traitorReserve!.includes(replacement.replacement!), false);
assert.equal(settled.players.flatMap((seat) => seat.traitors).includes(f.traitorTarget), false);
assert.equal(player(settled, f.moritani).spice, 12 + replacement.bounty);
assert.equal(settled.leaderSkills!.deck.filter((id) => id === 'killer-medic').length, 1);
assert.equal(settled.traitorReserve!.length, f.pending.traitorReserve!.length - 1);
assert.equal(settled.phase, 8);
assert.equal(viewGame(settled, f.opponent).moritaniAssassinate!.history.at(-1)!.card, f.traitorTarget);
assert.equal('replacement' in viewGame(settled, f.opponent).moritaniAssassinate!.history.at(-1)!, false);
assertMoritaniIndependentSkillsCustody(settled);
// Real quote of the revealed plans, including the committed specials.
const quote = quoteMoritaniIndependentSkillsBattle(f);
assert.equal(quote.result, 'normal');
assert.equal(quote.winner, f.opponent);
assert.deepEqual(quote.reinforcements, [{ player: f.moritani, card: REINFORCEMENTS_CARD,
normal: 3, elite: 0 }]);
assert.equal(quote.harassWithdraw!.length, 1);
assert.equal(quote.harassWithdraw![0].player, f.moritani);
assert.deepEqual(quote.harassWithdraw![0].returned, { normal: 2, elite: 0 });
assert.deepEqual(quote.harassWithdraw![0].remaining, {
normal: 4, normalFixedHalf: false, elite: 0, eliteStrength: 2, freeSupport: false });
assert.deepEqual(quote.harassWithdraw![0].locations, { [f.first]: { normal: 2, elite: 0 } });
});
void test('Advanced standalone Moritani keeps the printed power without Leader Skills, and a real decline changes nothing', () => {
const f = createMoritaniIndependentSkillsFixture({ rules: 'advanced', cards: true });
assert.equal(f.afterSetup.leaderSkills, undefined);
assert.equal(f.afterSetup.moritaniAssassinatePreview, true);
assert.equal(f.pending.decision?.kind, 'moritaniAssassinate');
const choice = choiceOf(f.pending, f.moritani);
assert.deepEqual(choice.cards.map((card) => card.card), [f.traitorTarget]);
assert.equal(choice.cards[0].bounty, owned(f, f.pending, f.traitorTarget).strength);
const spent = chooseMoritaniIndependentSkillsAssassination(f.pending, f.traitorTarget);
const target = owned(f, spent, f.traitorTarget);
assert.equal(target.dead, true);
assert.equal(player(spent, f.moritani).spice, 12 + spent.moritaniAssassinate!.opportunities.at(-1)!.bounty);
const settled = finishMoritaniIndependentSkillsBattle(spent, { winnerDiscard: 'played' });
assert.equal(settled.moritaniAssassinate!.opportunities.at(-1)!.stage, 'replaced');
assert.equal(settled.leaderSkills, undefined);
assert.equal(settled.discard.filter((card) => card.id === f.cards.winnerWeapon).length, 1);
assertMoritaniIndependentSkillsCustody(settled);
// A second identical lobby: declining is a real, state-preserving choice.
const declined = createMoritaniIndependentSkillsFixture({ rules: 'advanced', cards: true });
const before = structuredClone(declined.pending);
const after = chooseMoritaniIndependentSkillsAssassination(declined.pending, null);
const kept = owned(declined, after, declined.traitorTarget);
assert.equal(kept.dead, false);
assert.equal(kept.deaths, 0);
assert.equal(after.moritaniAssassinate!.opportunities.at(-1)!.stage, 'declined');
assert.equal(player(after, declined.moritani).spice, player(before, declined.moritani).spice);
assert.deepEqual(player(after, declined.moritani).traitors, [declined.traitorTarget]);
assert.deepEqual(player(after, declined.moritani).leaders, player(before, declined.moritani).leaders);
const ended = finishMoritaniIndependentSkillsBattle(after, { winnerDiscard: 'played' });
assert.deepEqual(ended.moritaniAssassinate!.opportunities, after.moritaniAssassinate!.opportunities);
assert.equal(ended.traitorReserve!.length, declined.pending.traitorReserve!.length);
assert.equal(viewGame(ended, declined.moritani).moritaniAssassinate!.history.length, 0);
assertMoritaniIndependentSkillsCustody(ended);
});
void test('a no-card no-Skills normal start still produces the Advanced assassination, including the native Harkonnen roster', () => {
const f = createMoritaniIndependentSkillsFixture({ rules: 'advanced', cards: false, opponent: 'harkonnen' });
const harkonnen = player(f.afterSetup, f.opponent);
// The plain 33-card classic inventory: no independent card is staged or played.
assert.equal(f.afterSetup.ecazTreachery, undefined);
assert.equal(f.cards.reinforcements, null);
assert.equal(f.cards.harass, null);
const cards = [...f.afterSetup.deck, ...f.afterSetup.discard,
...f.afterSetup.players.flatMap((seat) => seat.hand)];
assert.equal(cards.length, 33);
assert.equal(f.returns, null);
// The Harkonnen native roster: four dealt Traitor Cards and a real capture.
assert.equal(harkonnen.traitors.length, 4);
assert.equal(f.pending.decision?.kind, 'moritaniAssassinate');
const assassination = chooseMoritaniIndependentSkillsAssassination(f.pending, f.traitorTarget);
assert.equal(owned(f, assassination, f.traitorTarget).dead, true);
assert.equal(assassination.moritaniAssassinate!.opportunities.at(-1)!.bounty,
owned(f, f.pending, f.traitorTarget).strength);
const offer = resolveMoritaniIndependentSkillsBattle(f, assassination, { capture: { accept: true, captured: 'keep' } });
const settled = finishMoritaniIndependentSkillsBattle(offer,
{ winnerDiscard: 'played', capture: { accept: true, captured: 'keep' } });
const captives = settled.players.flatMap((seat) => seat.leaders)
.filter((leader) => leader.capturedBy === f.opponent);
assert.equal(captives.length, 1);
assert.equal(captives[0].faction, 'moritani');
assert.equal(captives[0].dead, false);
assert.equal(settled.moritaniAssassinate!.opportunities.at(-1)!.stage, 'replaced');
assert.equal(player(settled, f.moritani).tanks, 6);
assert.equal(settled.discard.filter((card) => card.id === f.cards.winnerWeapon).length, 1);
assertMoritaniIndependentSkillsCustody(settled);
});
void test('Basic independent Moritani resolves the same real Reinforcements/Harass loss without the Advanced assassination', () => {
const f = createMoritaniIndependentSkillsFixture({ rules: 'basic', cards: true, skills: true });
assert.equal(f.afterSetup.moritaniAssassinate, undefined);
assert.equal(f.afterSetup.moritaniAssassinatePreview, undefined);
assert.equal(f.afterSetup.leaderSkills!.assignments.length, 3);
const battle = f.revealed.battle!;
assert.equal(battle.plans[f.moritani].weapon, REINFORCEMENTS_CARD);
assert.equal(battle.plans[f.moritani].defense, HARASS_WITHDRAW_CARD);
assert.deepEqual(f.returns, { [f.first]: { normal: 4, elite: 0 } });
assert.ok(moritaniIndependentSkillsClean(f.pending));
assert.equal(f.pending.phase, 7);
assert.equal(f.pending.lastBattleContext!.result, 'normal');
assert.equal(f.pending.lastBattleContext!.winner, f.opponent);
assert.equal(f.pending.moritaniAssassinate, undefined);
const held = player(f.pending, f.moritani), rival = player(f.pending, f.opponent);
assert.equal(held.spice, 12);
assert.equal(held.reserves, 15);
assert.equal(held.tanks, 5);
assert.deepEqual(held.forces, {});
assert.deepEqual(held.traitors, [f.traitorTarget]);
assert.equal(rival.tanks, 1);
assert.deepEqual(rival.forces, { [f.second]: 4 });
assert.equal(owned(f, f.pending, f.traitorTarget).dead, false);
assert.equal(owned(f, f.pending, f.winnerLeader).dead, false);
assert.equal(f.pending.discard.filter((card) => card.id === REINFORCEMENTS_CARD).length, 1);
assert.equal(f.pending.discard.filter((card) => card.id === HARASS_WITHDRAW_CARD).length, 1);
assert.equal(f.pending.discard.filter((card) => card.id === f.cards.winnerWeapon).length, 1);
const settings = structuredClone(f.pending.leaderSkills);
const settled = finishMoritaniIndependentSkillsBattle(f.pending);
assert.deepEqual(settled.leaderSkills, settings);
assert.deepEqual(player(settled, f.moritani).traitors, [f.traitorTarget]);
assertMoritaniIndependentSkillsCustody(settled);
const plain = createMoritaniIndependentSkillsFixture({ rules: 'basic', cards: false, opponent: 'harkonnen' });
assert.equal(plain.afterSetup.moritaniAssassinate, undefined);
assert.equal(plain.pending.phase, 7);
assert.equal(plain.pending.moritaniAssassinate, undefined);
assert.deepEqual(player(plain.pending, plain.moritani).traitors, [plain.traitorTarget]);
assert.equal(player(plain.pending, plain.moritani).tanks, 6);
assert.equal(owned(plain, plain.pending, plain.traitorTarget).dead, false);
assertMoritaniIndependentSkillsCustody(plain.pending);
const variant = createMoritaniIndependentSkillsFixture({ rules: 'basic', cards: true });
assert.equal(variant.afterSetup.moritaniAssassinate, undefined);
assert.equal(variant.afterSetup.ecazTreachery, true);
assert.equal(variant.pending.phase, 7);
assert.equal(variant.pending.moritaniAssassinate, undefined);
assert.equal(variant.pending.lastBattleContext!.winner, variant.opponent);
assert.deepEqual(player(variant.pending, variant.moritani).traitors, [variant.traitorTarget]);
assert.equal(owned(variant, variant.pending, variant.traitorTarget).dead, false);
assert.equal(variant.pending.discard.filter((card) => card.id === REINFORCEMENTS_CARD).length, 1);
assert.equal(variant.pending.discard.filter((card) => card.id === HARASS_WITHDRAW_CARD).length, 1);
assertMoritaniIndependentSkillsCustody(variant.pending);
});
void test('a real normal Traitor call forfeits the printed power for the game and the used faction is never offered twice', () => {
const called = createMoritaniIndependentSkillsFixture({ rules: 'advanced', cards: true, traitor: 'winner' });
assert.equal(called.heldTraitor, called.winnerLeader);
assert.deepEqual(choiceOf(called.pending, called.moritani).cards, []);
const played = resolveMoritaniIndependentSkillsBattle(called, called.revealed,
{ traitorCalls: [called.moritani], winnerDiscard: 'played' });
assert.equal(played.lastBattleContext!.result, 'traitor');
assert.equal(played.lastBattleContext!.winner, called.moritani);
assert.equal(played.moritaniAssassinate!.normalTraitorCall, true);
assert.deepEqual(played.moritaniAssassinate!.opportunities, []);
assert.equal(played.moritaniAssassinateCallEvents!.includes(played.lastBattleContext!.event), true);
assert.equal(player(played, called.moritani).spice, 12 + owned(called, played, called.winnerLeader).strength);
assert.deepEqual(player(played, called.moritani).revealedTraitors, [called.winnerLeader]);
assert.ok((viewGame(played, called.moritani).moritaniAssassinate!.blocked ?? '').includes('forfeited'));
assert.equal(played.decision?.kind, undefined);
assertMoritaniIndependentSkillsCustody(played);
// The original once-per-faction rule survives a second real battle.
const f = createMoritaniIndependentSkillsFixture({ rules: 'advanced', cards: true });
const spent = chooseMoritaniIndependentSkillsAssassination(f.pending, f.traitorTarget);
let game = finishMoritaniIndependentSkillsBattle(spent, { winnerDiscard: 'played' });
assert.equal(game.turn, 1);
const actions: { actor: string; action: Action }[] = [];
game = advanceMoritaniIndependentSkills(game,
(state) => state.phase === 5 && state.active === f.moritani && moritaniIndependentSkillsClean(state), actions);
assert.equal(player(game, f.moritani).spice, 15);
game = stepMoritaniIndependentSkills(game,
{ actor: f.moritani, action: { type: 'ship', territory: f.territory, sector: Number(f.second.split(':')[1]),
amount: 4, elite: 0, allyPayment: 0 } });
game = advanceMoritaniIndependentSkills(game,
(state) => state.phase === 5 && state.active === f.moritani && moritaniIndependentSkillsClean(state) && !state.pendingShipment,
actions);
assert.deepEqual(player(game, f.moritani).forces, { [f.second]: 4 });
game = stepMoritaniIndependentSkills(game, { actor: f.moritani, action: { type: 'endMovement' } });
game = advanceMoritaniIndependentSkills(game,
(state) => state.phase === 6 && !state.battle && moritaniIndependentSkillsClean(state), actions);
const offered = viewGame(game, game.active!).battleChoices.find((entry) => entry.territory === f.territory);
assert.ok(offered);
game = stepMoritaniIndependentSkills(game, { actor: offered!.chooser, action: { type: 'chooseBattle',
territory: f.territory, target: offered!.attacker === offered!.chooser ? offered!.defender : offered!.attacker } });
game = advanceMoritaniIndependentSkills(game,
(state) => !!state.battle && !state.battle.revealed && moritaniIndependentSkillsClean(state), actions);
game = stepMoritaniIndependentSkills(game, { actor: f.moritani, action: {
type: 'battlePlan', dial: 2, support: 0, leader: f.loserLeader } });
game = stepMoritaniIndependentSkills(game, { actor: f.opponent, action: {
type: 'battlePlan', dial: 0, support: 0, leader: f.winnerLeader } });
const second = resolveMoritaniIndependentSkillsBattle(f, game, { winnerDiscard: 'played' });
assert.equal(second.lastBattleContext!.result, 'normal');
assert.equal(second.lastBattleContext!.winner, f.opponent);
assert.equal(second.moritaniAssassinate!.opportunities.length, 1);
assert.equal(second.moritaniAssassinate!.opportunities[0].stage, 'replaced');
assert.notEqual(second.decision?.kind, 'moritaniAssassinate');
assert.equal(viewGame(second, f.moritani).moritaniAssassinate!.pending, null);
assert.equal(viewGame(second, f.moritani).moritaniAssassinate!.history.length, 1);
assert.equal(player(second, f.moritani).spice, 7);
assert.equal(owned(f, second, f.traitorTarget).dead, true);
assertMoritaniIndependentSkillsCustody(second);
});
@@ -45,7 +45,6 @@ void test('Moritani skill starts retain immutable Ecaz and optional-module rejec
(g: Game) => { g.expansions.push('choam'); },
(g: Game) => { g.discoveryEnabled = true; g.expansions.push('choam'); },
(g: Game) => { g.discoveryEnabled = true; g.players[1] = newPlayer('a', 'Ecaz', 'ecaz'); },
(g: Game) => { g.ecazTreachery = true; },
]) {
const lobby = createGame('MORIGATE', newPlayer('m', 'Moritani', 'moritani'), false, ['ecaz']);
joinGame(lobby, newPlayer('a', 'Atreides', 'atreides'));
+2 -2
View File
@@ -122,9 +122,9 @@ for (const advanced of [false, true]) {
});
}
void test(`${advanced ? 'Advanced' : 'Basic'} selected Ix+CHOAM: nine paid suboids leave a real one-suboid/two-cyborg fee`, () => {
void test(`${advanced ? 'Advanced' : 'Basic'} selected Ix+CHOAM+Ecaz cards: nine paid suboids leave a real one-suboid/two-cyborg fee`, () => {
const f = createNativeFamilyReinforcementsFixture({ advanced, faction: 'ixians', mixedFee: true,
expansions: ['ix', 'choam'], slot: advanced ? 'weapon' : 'defense' });
expansions: ['ix', 'choam', 'ecaz'], slot: advanced ? 'weapon' : 'defense' });
const own = player(f.game, f.owner);
assert.equal(own.reserves, 5);
assert.equal(own.elites!.reserves, 4);