Compose independent Ecaz cards with selected phase modules

This commit is contained in:
Quintine committed 2026-10-26 11:18:50 +11:00
1 parent 68a7870e2e
commit 0a98a92f4b
17 files changed
+733 -15

No files matched your search

+31
View File
@@ -16,6 +16,37 @@ open mode gates.
## Current checkpoint and work
**Selected-deck independent cards / phase modules — 26 October. Prototyped / Partial.**
Clean independent cards now compose registered paired/mixed/all-three selected
families and classic-only unused decks with optional Tech3–6 Basic/Advanced and
Strongholds2–6 Advanced. Basic mixed Ecaz also starts bare cards without an
incidental Tech prerequisite. Mandatory coalition/assassination use existing
handlers; separate World/Skills/Nexus/Discovery/Semuta profiles remain.
Disjoint profile, native Recruits and phase/control lanes plus root integration
pass **11 files,75/75**, types/lint/build and **nine runtime paths**,26 GameTable
SSR renders and two actual selected-props Recruits callbacks, including busy
guards. Five paths are native: bare Basic E/I/T all3/independent50 starts in21
actions; Basic/Advanced all-six programmes retain60 identities, real token
payouts/earned cards (249/265). Actual native Recruits costs1 after real Ix
inspection, Moritani ships3 for6, loses3 to the next native Storm, then plays
the card and returns3 free under doubled2→4/limit7. Native Tleilaxu bank1 and
Axlotl delayed1 stay separate (241/261 source plus18/15 continuation actions).
All four minimal policies submit legal next actions from the new phase-five
boundaries. Original module controls/preview Start use SSR plus public actions.
Four controlled Ecaz/Ixian supplements retain actual setup/alliance/earned Tabr,
then conserved original cards/armies prove same-plan Reinforcements fee3,
score-only+2, card-user-only typed Harass returns, fixed/variable losses,
holder-only income, one disposal and actual selected-winner Tech. They are not
native invasion/acquisition histories. Feedback removes an assumed compulsory
outcome acknowledgement and narrows a nullable test-quote selection.
Frozen report/manifest: `/home/quintine/.local/share/dune/checkpoints/20261026-selected-independent-phase-proof-v2/`;
journeys/HTML: adjacent `20261026-selected-independent-phase-runtime/`.
No saved-room/D1/server, reset/restart, deployment or assurance operation.
Broader combinations, complete modes and browser/mobile/deployed acceptance
remain open. [Source and contract](ECAZ_TREACHERY_RULES.md#selected-independent-cards-and-phase-modules--26-october-2026).
**Selected-deck Tech / Stronghold composition — 26 October. Prototyped / Partial.**
Fresh normal starts now compose Tech3–6 Basic/Advanced and Strongholds2–6
Advanced across paired E3, mixed/all-three families and classic-only selected
+53
View File
@@ -2,6 +2,59 @@
Audit: 2026-09-06. Scope: Reinforcements, Recruits, and Harass & Withdraw. This document records component evidence and implementation boundaries; it does not certify an implemented expansion. No runtime files were changed.
## Selected independent cards and phase modules — 26 October 2026
The printed independent-variant rule (E3 pp9,11 above), together with supplied
Advanced physical21/printed19 variant independence, now composes clean selected
decks across registered paired/mixed/all-three native rosters and classic-only
unused decks. Each actual native still needs its selected family. Basic mixed
Ecaz also admits bare independent cards without an incidental Tech requirement.
Optional original Tech requires3–6 Basic/Advanced seats; Strongholds requires
2–6 Advanced, or3–6 beside Tech. Base/no-selected-deck play keeps its existing
route. Original36/38/50 Treachery and separate native Richese cache remain.
One public profile feeds setup and existing effect consumers. Mandatory Ecaz
Occupy and Advanced Moritani Assassinate retain native handlers; the existing
captured-leader target contract applies to Advanced Harkonnen/Moritani without
Skills/Worlds. Real first-Storm token ownership and actual end-Mentat card claims
remain separate from card acquisition. This does not widen bare mixed E3 Worlds,
Skills/Nexus/Discovery/Sandtrout or explicit Semuta; their own profiles remain.
Recruits still doubles current ordinary free rates and the ordinary seven
ceiling. Completed Revival turns do not reopen, and actual free returns earn
one pending Axlotl pile rather than immediate spending money. Separate native
Tleilaxu bank income remains. Reinforcements retains its existing provisional
normal-first three-reserve fee and score-only +2; it supplies no on-board
counters or support. Same-plan Harass retains only the card user's physical
undialed return, leaving the other coalition owner separate. Existing reveal/
fee/return, Stronghold-holder income, cleanup and selected-winner Tech order
remain inherited interpretations, not newly located combined rulings.
Frozen root proof passes11 files,75/75, types/lint/build and nine runtime paths,
26 GameTable SSR renders and two actual selected-props Recruits callbacks with
busy guards. Five paths are native: bare Basic mixed21 actions/card50, all-six
Basic249/Advanced265 programmes/card60, and genuine Recruits241/261 sources plus
18/15 continuation actions. Actual Ix inspection returns another card; Moritani
bids/pays1, ships3 for6, loses3 to native Basic dials2/Advanced card1, plays
Recruits and returns3 free at current doubled2→4/limit7. Original Tleilaxu
bank1 and Axlotl delayed1 stay distinct. Four minimal policies submit legal
next actions from the new phase-five boundaries.
Four explicitly controlled Ecaz/Ixian supplements retain actual setup, reciprocal
alliance and earned Advanced Tabr. Conserved original cards/armies then prove
same-plan fee3/+2 score, only the selected card user's typed undialed return,
fixed Ecaz versus variable Suboid casualties, holder-only Tabr1, one disposal
per card and original selected-winner Tech after cleanup. No fee/return creates
shipment or free-Revival token income. These are not natural card acquisitions
or invasion histories, complete combination campaigns or new numerical rulings.
Feedback removes an assumed compulsory outcome acknowledgement and narrows
nullable selection in the test quote. Report/manifest:
`/home/quintine/.local/share/dune/checkpoints/20261026-selected-independent-phase-proof-v2/`;
journeys/HTML: adjacent `20261026-selected-independent-phase-runtime/`.
No saved-game/server/reset/restart/deployment or assurance operation occurs.
Complete modes, wider combinations and browser/mobile/deployed acceptance remain
unfinished. No saved game is retrofitted or reset.
## Classic independent Nexus composition — 18 October 2026
The original independent-variant authority below now also connects fresh
+28
View File
@@ -1,5 +1,33 @@
# Dune implementation status
## 26 October 2026 — Selected independent cards and phase modules
**Prototyped / Partial.** Clean selected-deck
independent cards now compose registered paired/mixed/all-three native and
classic-only unused rosters, with optional Tech3–6 Basic/Advanced and
Strongholds2–6 Advanced. Basic mixed Ecaz also admits bare cards without Tech.
Existing printed calculations/native handlers remain; separate World/Skills/
Nexus/Discovery/Semuta profiles and complete-mode gates stay unchanged.
Frozen root proof passes **11 files,75/75**, types/lint/build and **nine runtime
paths**,26 GameTable SSR renders and two actual selected-props Recruits callbacks
with busy guards. Five native paths include bare mixed Basic21 actions/card50,
all-six Basic249/Advanced265 programmes/card60, and genuine Recruits sources241/
261 plus18/15 continuations. Real Ix inspection and Mori bid1 buy the card;
actual shipment3/cost6 and next Storm supply Tanks3. Recruits doubles2→4/limit7;
free3 keeps native Tleilaxu bank1 distinct from Axlotl delayed1. All four minimal
policies submit legal next actions. Four labelled controlled coalition paths
add actual same-plan reserve fee, owner-only typed return, original holder
income, disposal and winner Tech; no natural invasion/acquisition claim.
Feedback removes an assumed compulsory outcome acknowledgement and narrows a
nullable test-quote selection. Report/manifest:
`/home/quintine/.local/share/dune/checkpoints/20261026-selected-independent-phase-proof-v2/`;
journeys/HTML: adjacent `20261026-selected-independent-phase-runtime/`. No saved
game/server/reset/restart/deployment or assurance operation. Broader combinations,
browser/mobile/deployed and complete-mode acceptance remain open.
[Source and scope](ECAZ_TREACHERY_RULES.md#selected-independent-cards-and-phase-modules--26-october-2026).
## 26 October 2026 — Selected-deck Tech / Stronghold composition
**Prototyped / Partial.** Fresh clean normal starts
+1
View File
@@ -1483,6 +1483,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP
| Classic Nexus core / original phase modules | Fresh classic base33 or selected36/Nexus12, optionally14, composes original Tech3–6 Basic/Advanced and Strongholds2–6 Advanced through the public core capability. Real first-Storm tokens, owner-count pending income, actual end-Mentat held cards and original matching payout remain. Fixed paid force groups do not become free under Recruits; free requests accrue once, pending income cannot fund earlier costs, and fees/Harass are not Revival/shipment. Earned local bank support does not fund reserve fees; physical effective Carthag defense, native counters/cleanup/winner token/private replacement keep their original order. No new numerical or universal policy, played reflag or wider family/World/Discovery opening. | [Source, controls and owner-frozen439/439](NEXUS_CARD_RULES.md#classic-nexus-original-phase-modules--18-october-2026) |
| Original World Nexus core | Fresh base33 classic Homeworld/Nexus composes optional all14 and original Tech3+/Advanced Strongholds2+ through the public core capability; independent36 World/Nexus stays a separate pending own-World fee/conversion gate with explicit public messaging. Pending parents bind only present World custody; signed Cunning World frames and genuine discounted Smuggler invoices supply the funded Guild fee while standalone envelopes keep narrower sources. Typed visitor/native pools, printed tariffs, native permission/rate order, singleton spending, temporary-role support and ordinary-only casualties, native Traitor callers and original cleanup/replacement remain. No new numeric policy, played reflag or wider envelope opening. | [Owner-frozen448/448, scenes and limits](NEXUS_CARD_RULES.md#original-world-nexus-core--18-october-2026) |
| Fresh native Ecaz/Moritani defaults | Fresh actual Eca/Mori roles reuse registered selected-deck all14 capability, including mixed families and original World/phase/cards without Nexus. Printed Eca participation and Advanced Mori assassination initialize only for actual seats; Basic gains no Advanced power and unused decks create no faction seat. Bare rosters and original Nexus/Discovery/Semuta retain their envelopes; Advanced role/shared Duke guards stay. No played-save forfeiture/opportunity history is fabricated. | [Current registered source envelope](LEADER_SKILLS_RUNTIME.md#registered-native-e3-selected-decks--18-october-2026), [Mori authority](MORITANI_ASSASSINATE_LEADERS.md#connected-scope-and-private-information) |
| Selected independent cards / optional phase modules | Printed E3 variant independence and supplied Advanced physical21/printed19 compose clean registered selected-deck paired/mixed/all-three families and classic-only unused decks. Basic mixed Ecaz admits bare cards without incidental Tech; optional original Tech3–6 Basic/Advanced and Strongholds2–6 Advanced retain real setup/owners/claims. Existing Recruits current rates, separate Tleilaxu bank income, delayed Axlotl, normal-first reserve fee, owner-only coalition Harass and selected-winner Tech remain. Advanced native Assassinate uses the existing captured-leader contract; no new numerical ruling, bare mixed World/Skills/Nexus/Discovery/Semuta permission or played-game conversion. | [Current source and scope](ECAZ_TREACHERY_RULES.md#selected-independent-cards-and-phase-modules--26-october-2026) |
| Independent Treachery / original phase modules | Printed independence retains original Tech3–6 Basic/Advanced and/or Strongholds2–6 Advanced with classic/E1/E2 and original standalone/paired E3 bare cards; all14 also composes registered mixed selected families and Worlds. Real first-Storm/end-Mentat owners, separate delayed income and physical funding remain. Fees/H are not revival/shipment and +2 is not supported force. Native coalition/assassination and original queue stay source-labelled. Bare mixed E3 World/phase, original Nexus/Discovery/Semuta and role/effect guards remain. | [Original phase contract](ECAZ_TREACHERY_RULES.md#original-phase-module-composition--16-october-2026), [registered scope](LEADER_SKILLS_RUNTIME.md#registered-native-e3-selected-decks--18-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, optionally all14 Skills and original Tech/Advanced Strongholds. Retain current-rate/unlimited revival, accepted invoices versus separate native income, native-only World declarations and every own-World H ban. R normal-first sources retain pre-fee face/traits and fee/dial disjointness. Richese validates3 AFTER projected materialization before a first seal, never from later H returns or skill strength. Only committed battle losses feed Suk and actual board Cyborg losses feed substitution; fee sources are never duplicated. Existing timing remains provisional, not a new FAQ. | [Original native funding](ECAZ_TREACHERY_RULES.md#native-selected-family-world-composition--17-october-2026), [current Skills composition](LEADER_SKILLS_RUNTIME.md#world-skills-and-selected-decks--17-october-2026) |
+4
View File
@@ -264,6 +264,10 @@ No saved-game/server/reset/restart/deployment or assurance operation occurs.
Complete-mode, wider interactions, browser/mobile, deployed and publication
acceptance remain open.
Independent cards additionally use the [current selected-deck profile](ECAZ_TREACHERY_RULES.md#selected-independent-cards-and-phase-modules--26-october-2026)
with optional original phase modules. This is separate from the card-free
profile above and does not require Skills merely to select mixed decks.
## Standalone E3 Stronghold composition — 4 October 2026
Fresh `stronghold-factions` additionally admits Advanced native Ecaz OR native
+4 -3
View File
@@ -16,7 +16,7 @@ import { movementShipmentPreparations } from './movement-shipment-preparation';
import { MovementClaimError, liveMovementPromises, movementEventAllowed, movementPromiseModeSupported, movementPromiseTargetAvailable, settleMovementEvent, finishMovementPromises, validMovementClaim, type MovementClaim, type MovementPromise } from './movement-promises';
import { findMovementCompletion, type MovementCompletion } from './movement-promise-search';
import { classicNexusModulesProfile, mixedE1E2NexusProfile, mixedE3NexusProfile, pairedE3NexusProfile, pairedNexusModulesProfile, singleE1E2NexusProfile, standaloneE3NexusProfile } from './nexus-module-profile';
import { independentDiscoveryProfile, independentNexusProfile, nexusCoreProfile, nativeFactionTechProfile, selectedFactionStrongholdProfile } from './faction-module-profile';
import { independentDiscoveryProfile, independentNexusProfile, nexusCoreProfile, nativeFactionTechProfile, selectedFactionStrongholdProfile, selectedIndependentCardsProfile } from './faction-module-profile';
import { classicDiscoveryLeaderSkillsProfile, classicDiscoveryNexusProfile, homeworldDiscoveryProfile, discoveryModeSupported, discoveryModuleProfile, mixedE1E2DiscoveryNexusProfile, mixedE3DiscoveryNexusProfile, pairedDiscoveryNexusProfile, pairedE3DiscoveryNexusProfile, singleE1E2DiscoveryNexusProfile, standaloneE3DiscoveryNexusProfile } from './discovery-module-profile';
import { advancedMoritaniLeaderSkillsProfile, selectedDeckLeaderSkillsProfile, homeworldLeaderSkillsProfile, independentTreacheryLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, guildBetrayalNexusLeaderSkillsProfile, mixedE1E2NexusLeaderSkillsProfile, mixedE3NexusLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeEcazLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, pairedE3LeaderSkillsProfile, pairedE3NexusLeaderSkillsProfile, pairedNexusLeaderSkillsProfile, singleE1E2NexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile } from './leader-skill-profile';
import { bribeTimingBlock, maximumBribe, type BribeOptions } from './bribe-options';
@@ -11852,8 +11852,8 @@ function ecazOccupyCompositionSupported(g: Game): boolean {
// Native Basic uses the same physical coalition and selected decks.
// Family/roster checks below remain authoritative; optional configurations
// retain their separate registered profiles rather than inheriting admission.
const basicNatives = g.advanced === false &&
!g.ecazTreachery && (!g.techTokens || nativeFactionTechProfile(g)) && !g.strongholdCards;
const basicNatives = g.advanced === false && !g.strongholdCards &&
((!g.ecazTreachery && (!g.techTokens || nativeFactionTechProfile(g))) || selectedIndependentCardsProfile(g));
if ((independentNexusProfile(g) || independentDiscoveryProfile(g)) && byFaction(g, 'ecaz')) return true;
if (g.leaderSkills && nativeEcazLeaderSkillsProfile(g)) return true;
if (g.ecazOccupyPreview === true && (mixedE3NexusProfile(g) || standaloneE3NexusProfile(g) || pairedE3NexusProfile(g)) &&
@@ -12128,6 +12128,7 @@ function ecazHomeworldProfile(g: Game): boolean {
}
/** Independent cards compose original phase modules, not unrelated previews. */
function independentCardModulesProfile(g: Game): boolean {
if ((g.techTokens || g.strongholdCards) && selectedIndependentCardsProfile(g)) return true;
if ((g.techTokens || g.strongholdCards) && (independentNexusProfile(g) || independentDiscoveryProfile(g))) return true;
if ((g.techTokens || g.strongholdCards) && g.leaderSkills && !g.sandtrout &&
independentTreacheryLeaderSkillsProfile(g)) return true;
+17 -2
View File
@@ -15,6 +15,8 @@ export type FactionModuleProfile = {
discoveryStash?: unknown;
greatMaker?: unknown;
ecazTreachery?: unknown;
sandtrout?: unknown;
spiceBankerIncomePreview?: unknown;
semutaPreview?: unknown;
advancedPreview?: unknown;
kullPreview?: unknown;
@@ -132,11 +134,11 @@ export function selectedPhaseModuleRoster(
/** Keep unrelated overlays outside this phase-only permission. Semuta's caller
* additionally retains its old roster envelope below, rather than inheriting
* new selected-family admission through its explicit context flag. */
function phaseModuleContext(game: FactionModuleProfile, semuta = false): boolean {
function phaseModuleContext(game: FactionModuleProfile, semuta = false, independent = false): boolean {
return typeof game.advanced === 'boolean' &&
!(game.strongholdCards && !game.advanced) && !game.leaderSkills &&
!game.homeworlds && !game.nexusCards && !game.discoveryEnabled && !game.discoveries &&
!game.discoveryStash && !game.greatMaker && !game.ecazTreachery &&
!game.discoveryStash && !game.greatMaker && (independent ? game.ecazTreachery === true : !game.ecazTreachery) &&
(!game.semutaPreview || (semuta && game.semutaPreview === true)) &&
!game.advancedPreview && !game.kullPreview && !game.nexusKullPreview &&
!game.guildBetrayalPreview && !game.richeseBetrayalPreview &&
@@ -193,6 +195,19 @@ export function selectedFactionStrongholdProfile(game: FactionModuleProfile): bo
selectedPhaseModuleRoster(game) && (!game.techTokens || game.players.length >= 3);
}
/** Independent physical cards with optional original phase modules. This is a
* selected-deck permission; base-only, World and trained compositions retain
* their separate original profiles and physical freshness stays external. */
export function selectedIndependentCardsProfile(game: FactionModuleProfile): boolean {
if (!game || game.ecazTreachery !== true ||
!selectedPhaseModuleRoster(game) || !phaseModuleContext(game, false, true) ||
(game.techTokens && game.players.length < 3) || game.sandtrout || game.spiceBankerIncomePreview) return false;
return (!game.ecazOccupyPreview || game.ecazOccupyPreview === true &&
game.players.some(player => player.faction === 'ecaz')) &&
(!game.moritaniAssassinatePreview || game.moritaniAssassinatePreview === true && game.advanced &&
game.players.some(player => player.faction === 'moritani'));
}
export type MixedE3NativeRoster = {
advanced?: boolean;
expansions: readonly string[];
+4 -4
View File
@@ -3901,7 +3901,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Classic Advanced Nexus core also retains original Strongholds2–6 and optional Tech3–6 with base33 or selected independent36, optionally all14. Earn cards only at actual end-Mentat. Earned Arrakeen composes temporary-Sardaukar support and real Reinforcements reserve fees without making bank money personal; earned Carthag still requires a physical effective defense after an original Harkonnen call is canceled. Original cleanup, token reward, held custody and private Mentat replacement remain.',
'Fresh Advanced all14 Skills uses registered actual seats and selected ordinary decks with original Strongholds2–6 and optional Tech3–6, including its independent-card/World envelope. Native E3 needs no Nexus or exact single family merely to use Skills. Original Nexus/Discovery/Semuta card contexts and Advanced role guards remain separate. Actual end-Mentat claims stay. Arrakeen funds only local holder-plan support, never native World support, personal cash or reserve fees; normal Banker uses the final payer debit. Rescue, cleanup, Tech and Face Dance retain their order.',
'Ecaz/Moritani Skills may include other registered selected-family seats and unused selected ordinary decks, with original Worlds/phase modules and optional independent cards. Native five-disc training and original role exclusions remain. Normal Warmaster one on a temporary Duke never grants trained three. Winner Suk precedes unused-disc assassination, bounty, winner Tech and one Mentat replacement. Ecaz-held support belongs only to the selected Ecaz plan, not an allied plan; fixed and variable armies remain separate. Advanced E3/Harkonnen, shared Duke and effect-specific guards stay.',
'Fresh independent Treachery previews retain classic/E1/E2 and original standalone/paired E3 bare rosters; all14 additionally admits registered mixed selected families. Original Worlds, Advanced Strongholds2–6 and optional Tech3–6 retain actual end-Mentat custody. Reinforcements adds no supported counters; Harass vacates control without transferring a held card early. Exact native fees/returns, card effects and winner Tech remain; bare mixed-E3 World/phase, original Nexus/Discovery card and role/effect guards stay separate.',
'Fresh independent Treachery previews retain clean registered selected-deck paired/mixed/all-three native rosters and classic-only unused decks, with optional Tech3–6 and Advanced Strongholds2–6. Basic mixed Ecaz also admits bare cards. Original end-Mentat custody, physical reserve fees, owner-only Harass returns and selected-winner Tech remain. Bare mixed E3 Worlds and separate Skills/Nexus/Discovery/Semuta profiles retain their own admission.',
'The six effects, public ownership and pre-plan choice are integrated in development games. Enable Stronghold Cards in an Advanced preview lobby. Complete Advanced compliance and every expansion interaction remain under verification.',
],
example:
@@ -3982,7 +3982,7 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area: 'Player controls', status: 'Implemented', detail: 'Original local390px controls cover HMS copy/support, real cache Stone reveal mode and partial Face Dance replacement. Standalone E3 human controls select the Ecaz holder lead, show bank2/personal1 for actual support3, seal native plans and keep the physical Shield; Moritani reveals printed Master Bewt bounty through its original inspector and choice. Original Collection and Mentat continue after refresh.', evidence: ['components/stronghold-cards.tsx', 'components/game-table.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Four minimal legal policies consume actual marker/Stone/FaceDance/copy choices; eighteen genuine paired/mixed Advanced2..6 games complete. Strategy/calibration and other combinations remain deferred.', evidence: ['game/bots.ts', 'tools/faction-games.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Advanced family/deck/cache admission, existing six effects, original winner versus replacement and explicit guarded combinations are recorded without completing every card or faction.', evidence: ['docs/STRONGHOLD_CARDS.md', 'docs/RULE_DECISIONS.md'] },
{ area: 'Verification', status: 'Partial', detail: 'New selected-deck cases cover normal mixed/classic setup, actual six-native income and claims, native Harkonnen/Moritani assassination and labelled controlled Ecaz/Ixian coalition battles. Full modes remain unfinished. Prior E1/E2 evidence remains dated. Standalone E3 adds20 focused cases, affected native/CLI179, types/lint/build and actual controlled holder-only funding, tie/income, assassination/death/forfeiture and four legal policies. Four genuine two/three-seat E3 games complete642 accepted actions,15 JSON continuations and3 battles without rejection; no natural assassination claim. Human390px Ecaz reaches v18/Mentat with bank2/personal1, separate Collection2 and physical Ecaz2/Guild1 survivors; Moritani reaches v11/Mentat with wallet18 and one original private replacement. No new assurance/full-suite campaign or public/deployed certification.', evidence: ['tests/phase-module-profile.test.ts', 'tests/mixed-phase-modules-runtime.test.ts', 'tests/mixed-phase-modules-controls.test.tsx', 'tests/phase-module-ecaz-runtime.test.ts', 'tests/phase-module-moritani-runtime.test.ts', 'tests/ecaz-stronghold-runtime.test.ts', 'tests/moritani-stronghold-runtime.test.ts', 'tests/richese-strongholds-runtime.test.ts', 'tests/tleilaxu-strongholds-runtime.test.ts'] },
{ area: 'Verification', status: 'Partial', detail: 'New selected-deck cases cover normal mixed/classic setup, actual six-native income and claims, native Harkonnen/Moritani assassination and labelled controlled Ecaz/Ixian coalition battles. Full modes remain unfinished. Prior E1/E2 evidence remains dated. Standalone E3 adds20 focused cases, affected native/CLI179, types/lint/build and actual controlled holder-only funding, tie/income, assassination/death/forfeiture and four legal policies. Four genuine two/three-seat E3 games complete642 accepted actions,15 JSON continuations and3 battles without rejection; no natural assassination claim. Human390px Ecaz reaches v18/Mentat with bank2/personal1, separate Collection2 and physical Ecaz2/Guild1 survivors; Moritani reaches v11/Mentat with wallet18 and one original private replacement. No new assurance/full-suite campaign or public/deployed certification.', evidence: ['tests/independent-phase-profile.test.ts', 'tests/independent-phase-modules-runtime.test.ts', 'tests/independent-phase-ecaz-battle.test.ts', 'tests/phase-module-profile.test.ts', 'tests/mixed-phase-modules-runtime.test.ts', 'tests/mixed-phase-modules-controls.test.tsx', 'tests/phase-module-ecaz-runtime.test.ts', 'tests/phase-module-moritani-runtime.test.ts', 'tests/ecaz-stronghold-runtime.test.ts', 'tests/moritani-stronghold-runtime.test.ts', 'tests/richese-strongholds-runtime.test.ts', 'tests/tleilaxu-strongholds-runtime.test.ts'] },
],
},
{
@@ -4001,7 +4001,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Owning all three tokens counts as one additional stronghold at victory checks. An alliance cannot combine separate token holdings to qualify: one member must own the complete set. Multiple sides reaching the victory target can share the win.',
'Token ownership and pending income are public and persist. Fresh registered selected-family all14 keeps original Tech3–6 Basic/Advanced and optional Advanced Strongholds2–6, including supported Worlds and independent cards, without an incidental Nexus or single-family E3 requirement. Original Nexus/Discovery/Semuta and role/effect guards remain. Rescue/board-spice precedes winner Tech and Face Dance. Mandatory normal Banker waits for Mentat, separate from phase-end Tech; fees/H returns are not revival/shipment. Native CHOAM opening income is not Charity. Two-player Tech and full combined acceptance remain unfinished.',
'Without other optional overlays, fresh normal selected-deck starts retain original Tech3–6 Basic/Advanced with registered paired/mixed/all-three native families or classic-only seats and unused decks. Advanced Strongholds2–6 optionally compose. Native payments, typed casualties, selected Ecaz lead rewards and existing captured-leader Moritani assassination remain separate; split allied holdings never form a complete set. Explicit Semuta keeps its earlier scope. Existing provisional Basic odd Occupy, Richese rulings and unrelated profiles are not newly decided; played games are not converted.',
'Classic/E1/E2 and original standalone/paired E3 bare independent-card previews retain Worlds, Tech3–6 and optional Advanced Strongholds2–6. All14 extends this to registered mixed selected families. Recruits changes current native rates without repeating Axlotl or advancing payout. Harass deposits and reserve fees are not free Revival or off-planet shipment. Original phase income/native custody/winner rewards remain; bare mixed-E3 World/phase and unrelated module/role guards stay separate.',
'Clean independent-card selected-deck rosters include paired/mixed/all-three natives and classic-only unused families with optional Tech3–6 and Advanced Strongholds2–6. Recruits changes current native rates without repeating Axlotl or advancing payout. Harass deposits and reserve fees are not free Revival or off-planet shipment. Original phase income, mandatory coalition/assassination and winner rewards remain. Bare mixed E3 Worlds and unrelated module/role profiles keep separate admission.',
'Classic Nexus core composes original Tech3–6 Basic/Advanced and optional Advanced Strongholds2–6 with base33 or selected independent36, optionally all14. Real first-Storm owners and actual token counts set income. A paid fixed Tleilaxu force group does not become free because Recruits is active; subsequent genuine free returns accrue one Axlotl pile, not one pile per request. Pending spice cannot fund Revival before phase end. Harass returns and reserve fees are not token-triggering Revival or shipment; actual battle winners take one original opposing token.',
],
example:
@@ -4019,7 +4019,7 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area: 'Player controls', status: 'Partial', detail: 'Normal mixed selected-deck module controls and Start reach original setup/Storm; separate existing five-module lobby reaches original training/Storm. Native fixed-five and ordinary-free controls show original prices, physical Tanks and pending pile separately; actual phase close pays it. Stronghold and card inspectors retain their own entitlements.', evidence: ['components/game-table.tsx', 'tests/fixture-nexus-core-tech.ts'] },
{ area: 'AI', status: 'Partial', detail: 'Four existing own-view policies legally claim funded card shipment, complete real Revival into phase-end payout and finish earned Stronghold/Nexus battles. No strategic calibration or fixed-choice pin.', evidence: ['game/bots.ts', 'tests/fixture-nexus-core-tech.ts', 'tests/fixture-nexus-core-strongholds.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Printed trigger/exception, owner count, pending-versus-spendable spice, original payout, winner reward and complete-set victory remain distinct. Current classic core phase scope and inherited timing are explicit; full module/faction combinations and assurance remain open.', evidence: ['game/reference.ts', 'docs/NEXUS_CARD_RULES.md', 'docs/STRONGHOLD_CARDS.md', 'docs/RULE_DECISIONS.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Original-action consumers cover funded five-for-one delivery, paid fixed5 versus free2, a second genuine free request, pending-income affordability and Harass/winner transfer. Actual all14 Revival UI and four legal policies reach payout; older broader and saved evidence remains dated.', evidence: ['tests/nexus-core-tech-runtime.test.ts', 'tests/fixture-nexus-core-tech.ts', 'tests/tech-tokens.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-skills-modules-payments-runtime.test.ts'] },
{ area: 'Verification', status: 'Partial', detail: 'Original-action consumers cover funded five-for-one delivery, paid fixed5 versus free2, a second genuine free request, pending-income affordability and Harass/winner transfer. Actual all14 Revival UI and four legal policies reach payout; older broader and saved evidence remains dated.', evidence: ['tests/selected-phase-recruits-runtime.test.ts', 'tests/independent-phase-modules-runtime.test.ts', 'tests/nexus-core-tech-runtime.test.ts', 'tests/fixture-nexus-core-tech.ts', 'tests/tech-tokens.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-skills-modules-payments-runtime.test.ts'] },
],
},
{
+42
View File
@@ -0,0 +1,42 @@
import assert from 'node:assert/strict';
import { viewGame, type Game } from '../game/engine';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import { HARASS_WITHDRAW_CARD, defaultHarassWithdrawAllocation } from '../game/harass-withdraw';
import { stageEcazOccupyCard } from './fixture-ecaz-occupy';
import {
createPhaseModuleEcazFixture, actPhaseModuleEcaz as act, advancePhaseModuleEcaz as advance,
phaseModuleEcazClean as clean, phaseModuleEcazInventory as inventory, quotePhaseModuleEcazBattle,
type PhaseModuleEcazStep,
} from './fixture-phase-module-ecaz';
/** Controlled physical card/army supplement after real public setup, alliance
* and earned card. Every original wallet/phase/token owner remains native. */
export function createIndependentPhaseEcazFixture(options: { advanced?: boolean; lead?: 'ecaz' | 'ally' } = {}) {
const f = createPhaseModuleEcazFixture({ ...options, independent: true });
const game = structuredClone(f.game);
stageEcazOccupyCard(game, f.actor, card => card.id === REINFORCEMENTS_CARD);
stageEcazOccupyCard(game, f.actor, card => card.id === HARASS_WITHDRAW_CARD);
const planActions = f.planActions.map(step => step.actor === f.actor
? { actor: step.actor, action: { ...step.action, weapon: REINFORCEMENTS_CARD, defense: HARASS_WITHDRAW_CARD } } : step);
assert.deepEqual(inventory(game), f.originalInventory);
return { ...f, game, rawGame: structuredClone(game), planActions,
staging: [...f.staging,
'CONTROLLED conserved original Reinforcements and Harass & Withdraw custody replaces the selected plan slots; the unused Shield remains held. Neither special was acquired through a claimed native purchase. No fee, return, card disposal or token transfer is installed.'] };
}
export type IndependentPhaseEcazFixture = ReturnType<typeof createIndependentPhaseEcazFixture>;
export function resolveIndependentPhaseEcaz(f: IndependentPhaseEcazFixture, state: Game = structuredClone(f.game)) {
const actions: PhaseModuleEcazStep[] = [];
let game = state;
for (const step of f.planActions) game = act(game, step.actor, step.action, actions);
assert.ok(game.battle?.revealed);
while (game.decision?.kind === 'harassWithdraw') {
const decision = game.decision;
const offer = viewGame(game, decision.player).battle!.harassAllocation; assert.ok(offer);
const returns = defaultHarassWithdrawAllocation(offer.context, offer.dial, offer.support);
game = act(game, decision.player, { type: 'decision', event: decision.event, returns }, actions);
}
const revealed = structuredClone(game), quote = quotePhaseModuleEcazBattle(game);
game = advance(game, state => !state.battle && clean(state), actions);
return { game, revealed, quote, actions };
}
+6 -3
View File
@@ -13,6 +13,7 @@ export type MixedPhaseModulesOptions = {
advanced?: boolean;
tech?: boolean;
strongholds?: boolean;
independent?: boolean;
};
export const MIXED_PHASE_NATIVE_FACTIONS = ['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani'] as const;
export const actMixedPhaseModules = actAdvisorPreparation;
@@ -74,6 +75,7 @@ export function createMixedPhaseModulesFixture(options: MixedPhaseModulesOptions
for (const faction of others) joinGame(game, newPlayer(faction, faction, faction));
const initial = structuredClone(game), actions: MixedPhaseModulesStep[] = [];
const act = (actor: string, action: Action) => { game = actMixedPhaseModules(game, actor, action, actions); };
if (options.independent) act(host, { type: 'moduleOption', module: 'ecazTreachery', enabled: true });
if (options.tech) act(host, { type: 'techTokens', enabled: true });
if (options.strongholds) act(host, { type: 'strongholdCards', enabled: true });
for (const player of game.players) act(player.id, { type: 'ready' });
@@ -95,7 +97,8 @@ export function createMixedPhaseModulesFixture(options: MixedPhaseModulesOptions
game = advanceMixedPhaseModules(game, state => state.phase === 1 && mixedPhaseModulesClean(state), actions);
const firstStorm = structuredClone(game);
assert.deepEqual(mixedPhaseModulesInventory(game), originalInventory);
return { game, sourceGame: structuredClone(game), initial, lobby, setup, afterSetup, firstStorm,
return { game, sourceGame: structuredClone(game), independent: options.independent ?? false,
initial, lobby, setup, afterSetup, firstStorm,
originalInventory, originalSpice, spiceOrder, actions, sourceActions: structuredClone(actions),
sourceActionCount: actions.length, staging: [] as string[] };
}
@@ -104,10 +107,10 @@ export function createMixedPhaseModulesFixture(options: MixedPhaseModulesOptions
* accrue Heighliners once; native first Mentat claims precede a real later
* Storm casualty (Basic dials2 / Advanced card1) and that owner's free return. Sources remain ordinary public
* entry plus original native choices, with no played-state installation. */
export function createMixedPhaseModulesProgramme(options: { advanced?: boolean; strongholds?: boolean } = {}) {
export function createMixedPhaseModulesProgramme(options: { advanced?: boolean; strongholds?: boolean; independent?: boolean } = {}) {
const advanced = options.advanced ?? false, strongholds = options.strongholds ?? advanced;
const source = createMixedPhaseModulesFixture({ factions: MIXED_PHASE_NATIVE_FACTIONS,
expansions: ['ix', 'choam', 'ecaz'], advanced, tech: true, strongholds });
expansions: ['ix', 'choam', 'ecaz'], advanced, tech: true, strongholds, independent: options.independent });
let game = source.game;
const actions = [...source.actions];
const act = (actor: string, action: Action) => { game = actMixedPhaseModules(game, actor, action, actions); };
+16 -3
View File
@@ -7,6 +7,10 @@ import { ownedTech } from '../game/tech-tokens';
import { stageEcazOccupyCard } from './fixture-ecaz-occupy';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
import { actAdvisorPreparation } from './fixture-advisor-preparation';
import { ecazTreacheryCards } from '../game/ecaz-cards';
import { usesNativeBattleSpecial } from '../game/battle-card-slots';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
export type PhaseModuleEcazStep = { actor: string; action: Action };
export const phaseModuleEcazClean = (game: Game) => !game.phaseOpening && !game.response &&
@@ -19,7 +23,8 @@ export function phaseModuleEcazInventory(game: Game) {
...(game.ixSetupCards ?? []), ...(game.ixAuction?.cards ?? []),
...(game.auction?.cards.slice(game.auction.index + Number(!!game.currentAuctionSale)) ?? [])]
.map(card => card.id).sort();
assert.deepEqual(cards, treacheryDeck(game.expansions).map(card => card.id).sort());
assert.deepEqual(cards, [...treacheryDeck(game.expansions), ...(game.ecazTreachery ? ecazTreacheryCards() : [])]
.map(card => card.id).sort());
assert.equal(new Set(cards).size, cards.length);
for (const player of game.players) {
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, count) => sum + count, 0), 20);
@@ -75,7 +80,7 @@ function orderBlow(game: Game, worm: boolean, position: number) {
* real end-Mentat checkpoint earns Ecaz's Tabr card. Explicit conserved board
* and Shield staging is a later coalition supplement, not natural invasion or
* acquisition evidence. Never stage a wallet, phase, token owner or outcome. */
export function createPhaseModuleEcazFixture(options: { advanced?: boolean; lead?: 'ecaz' | 'ally' } = {}) {
export function createPhaseModuleEcazFixture(options: { advanced?: boolean; lead?: 'ecaz' | 'ally'; independent?: boolean } = {}) {
const advanced = options.advanced ?? false, lead = options.lead ?? 'ecaz';
const ecaz = 'ecaz', ally = 'ixians', opponent = 'emperor', actor = lead === 'ecaz' ? ecaz : ally;
let game = createGame('PHASEMODULEECAZ', newPlayer(ecaz, 'Ecaz', 'ecaz'), advanced, ['ix', 'choam', 'ecaz']);
@@ -85,6 +90,7 @@ export function createPhaseModuleEcazFixture(options: { advanced?: boolean; lead
const reach = (until: (state: Game) => boolean) => { game = advancePhaseModuleEcaz(game, until, actions); };
act(ecaz, { type: 'techTokens', enabled: true });
if (advanced) act(ecaz, { type: 'strongholdCards', enabled: true });
if (options.independent) act(ecaz, { type: 'moduleOption', module: 'ecazTreachery', enabled: true });
for (const player of game.players) act(player.id, { type: 'ready' });
const ready = structuredClone(game);
act(ecaz, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
@@ -167,7 +173,14 @@ export function quotePhaseModuleEcazBattle(game: Game) {
const player = phaseModuleEcazPlayer(game, actor), own = viewGame(game, actor).battle!; assert.ok(own.ownForces);
return { id: actor, faction: player.faction, ally: player.ally, spice: player.spice, hand: player.hand,
plan: battle.plans[actor], leader: player.leaders.find(leader => leader.id === battle.plans[actor].leader),
forces: own.ownForces, stronghold: own.strongholdEffects[actor] };
forces: own.ownForces, stronghold: own.strongholdEffects[actor],
...(usesNativeBattleSpecial(battle.plans[actor], REINFORCEMENTS_CARD)
? { reinforcementsReserves: { normal: player.reserves - (player.elites?.reserves ?? 0),
elite: player.elites?.reserves ?? 0 } } : {}),
...(usesNativeBattleSpecial(battle.plans[actor], HARASS_WITHDRAW_CARD) && own.harassWithdraw
? { harassWithdraw: { ...own.harassWithdraw, blocked: null },
...(battle.harassAllocation?.player === actor && battle.harassAllocation.stage === 'selected' && battle.harassAllocation.selection
? { harassSelection: battle.harassAllocation.selection } : {}) } : {}) };
};
return quoteBattleResolution({ advanced: game.advanced, turn: game.turn, territory: battle.territory,
ecazOccupy: profile, aggressor: view.aggressor, attacker: side(battle.attacker), defender: side(battle.defender),
+141
View File
@@ -0,0 +1,141 @@
import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import { TERRITORIES, location } from '../game/board';
import { RECRUITS_CARD_ID, recruitsPlayAction } from '../game/recruits';
import {
createMixedPhaseModulesFixture, MIXED_PHASE_NATIVE_FACTIONS,
actMixedPhaseModules, mixedPhaseModulesClean, mixedPhaseModulesInventory,
mixedPhaseModulesPlayer, nextMixedPhaseModules,
type MixedPhaseModulesStep, type MixedPhaseModulesTransition,
} from './fixture-mixed-phase-modules';
export const actSelectedPhaseRecruits = actMixedPhaseModules;
export const selectedPhaseRecruitsClean = mixedPhaseModulesClean;
export const selectedPhaseRecruitsInventory = mixedPhaseModulesInventory;
export const selectedPhaseRecruitsPlayer = mixedPhaseModulesPlayer;
export type SelectedPhaseRecruitsStep = MixedPhaseModulesStep;
export type SelectedPhaseRecruitsTransition = MixedPhaseModulesTransition;
/** The actual Ixian inspection keeps Recruits in its original auction pool;
* a real Mori bid1 buys that unopened first ordinary lot. All other native
* choices retain the shared source driver and its physical phase accounting. */
export function nextSelectedPhaseRecruits(game: Game): SelectedPhaseRecruitsStep | null {
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard && game.decision?.kind === 'ixAuction') {
const returned = game.ixAuction!.cards.find(card => card.id !== RECRUITS_CARD_ID); assert.ok(returned);
return { actor: game.decision.player,
action: { type: 'decision', card: returned.id, position: 'bottom' } };
}
const auction = game.auction;
if (selectedPhaseRecruitsClean(game) && auction?.cards[auction.index]?.id === RECRUITS_CARD_ID &&
auction.active === 'moritani' && auction.bid === 0)
return { actor: 'moritani', action: { type: 'bid', amount: 1 } };
return nextMixedPhaseModules(game);
}
export function advanceSelectedPhaseRecruits(game: Game, until: (state: Game) => boolean,
actions: SelectedPhaseRecruitsStep[] = []): Game {
for (let count = 0; count < 2200; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original Recruits source ended before its next control');
const step = nextSelectedPhaseRecruits(game);
assert.ok(step, `Choose the actual native control at ${game.turn}/${game.phase}/${game.decision?.kind}`);
game = actSelectedPhaseRecruits(game, step.actor, step.action, actions);
}
throw Error('The original selected-phase Recruits source did not reach its finite boundary');
}
export function finishSelectedPhaseRecruits(game: Game, actions: SelectedPhaseRecruitsStep[] = []): Game {
return advanceSelectedPhaseRecruits(game, state => state.phase === 5 && selectedPhaseRecruitsClean(state), actions);
}
/** All six native seats and all three original family decks. Public independent
* cards/Tech selections and optional Advanced Strongholds precede normal start.
* The sole new lottery is the still-unopened remaining Recruits position before
* the first ordinary auction. Actual inspection, bid/payment/acquisition,
* shipment3, next Storm and phase progression supply the played source.
* No played hand, force, wallet, Tanks, phase, queue or receipt is installed. */
export function createSelectedPhaseRecruitsFixture(options: { advanced?: boolean } = {}) {
const advanced = options.advanced ?? false, owner = 'moritani', bankOwner = 'tleilaxu';
const source = createMixedPhaseModulesFixture({ factions: MIXED_PHASE_NATIVE_FACTIONS,
expansions: ['ix', 'choam', 'ecaz'], advanced, tech: true, strongholds: advanced, independent: true });
let game = source.game;
const actions = [...source.actions];
const act = (actor: string, action: Action) => { game = actSelectedPhaseRecruits(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceSelectedPhaseRecruits(game, until, actions); };
const transition = (run: () => void): SelectedPhaseRecruitsTransition => {
const before = structuredClone(game), from = actions.length; run();
return { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
};
assert.equal(game.phase, 1); assert.ok(game.ecazTreachery && game.techTokens);
const beforeAuctionOrder = structuredClone(game);
const from = game.deck.findIndex(card => card.id === RECRUITS_CARD_ID); assert.ok(from >= 0,
'The original scalar setup leaves physical Recruits undealt for this ordinary auction');
const recruits = game.deck.splice(from, 1)[0]; game.deck.unshift(recruits);
const unopenedAuctionOrder = { card: recruits.id, from, to: 0 };
assert.deepEqual(selectedPhaseRecruitsInventory(game), source.originalInventory);
let beforeBid: Game | undefined, bid: SelectedPhaseRecruitsStep | undefined, bidAt = -1;
for (let count = 0; !selectedPhaseRecruitsPlayer(game, owner).hand.some(card => card.id === RECRUITS_CARD_ID) && count < 1000; count++) {
const step = nextSelectedPhaseRecruits(game); assert.ok(step);
if (step.actor === owner && step.action.type === 'bid' && step.action.amount === 1) {
beforeBid = structuredClone(game); bid = structuredClone(step); bidAt = actions.length;
assert.equal(game.auction?.index, 0, 'Recruits is the actual first ordinary lot');
}
act(step.actor, step.action);
}
assert.ok(beforeBid && bid && bidAt >= 0);
const acquisition = { before: beforeBid, bid, steps: structuredClone(actions.slice(bidAt)), after: structuredClone(game) };
assert.equal(selectedPhaseRecruitsPlayer(beforeBid, owner).spice - selectedPhaseRecruitsPlayer(game, owner).spice, 1);
reach(state => state.turn === 1 && state.phase === 5 && state.active === owner && selectedPhaseRecruitsClean(state));
const sector = game.storm % 18 + 1;
const target = TERRITORIES.find(t => t.type === 'sand' && t.id !== 'imperial_basin' && t.sectors.includes(sector) &&
game.players.every(player => !Object.entries(player.forces).some(([key, count]) => count > 0 && key.startsWith(`${t.id}:`))));
assert.ok(target, 'A native empty desert lies in the next Storm traversal');
const casualtyLocation = location(target.id, sector);
const shipment = transition(() => {
act(owner, { type: 'ship', territory: target.id, sector, amount: 3, elite: 0, allyPayment: 0 });
reach(selectedPhaseRecruitsClean);
});
assert.equal(selectedPhaseRecruitsPlayer(game, owner).forces[casualtyLocation], 3);
assert.equal(selectedPhaseRecruitsPlayer(shipment.before, owner).spice - selectedPhaseRecruitsPlayer(game, owner).spice, 6);
reach(state => state.turn === 1 && state.phase === 8 && selectedPhaseRecruitsClean(state));
const mentat = transition(() => {
reach(state => state.turn === 2 && state.phase === 0 && state.stormPending !== null && selectedPhaseRecruitsClean(state));
});
assert.equal(game.stormMovementSource?.kind, advanced ? 'card' : 'dials');
assert.equal(game.stormPending, advanced ? 1 : 2);
const storm = transition(() => { reach(state => state.turn === 2 && state.phase === 1 && selectedPhaseRecruitsClean(state)); });
assert.equal(selectedPhaseRecruitsPlayer(game, owner).forces[casualtyLocation], undefined);
assert.equal(selectedPhaseRecruitsPlayer(game, owner).tanks, 3);
reach(state => state.turn === 2 && state.phase === 4 && selectedPhaseRecruitsClean(state) &&
(!state.revivalOrder || state.revivalOrder.remaining[0] === owner));
const playAction = recruitsPlayAction(viewGame(game, owner).recruitsPreview); assert.ok(playAction);
const revivalAction: Action = { type: 'revive', amount: 3, elite: 0 };
assert.deepEqual(selectedPhaseRecruitsInventory(game), source.originalInventory);
return { ...source, game, sourceGame: structuredClone(game), advanced, owner, bankOwner,
card: RECRUITS_CARD_ID, playAction, revivalAction, beforeAuctionOrder, unopenedAuctionOrder,
acquisition, shipment, mentat, storm, casualtyLocation,
actions, sourceActions: structuredClone(actions), sourceActionCount: actions.length,
staging: [] as string[], lottery: 'Original unopened deal/Spice/scalar choices from the shared native source, plus unopened Recruits first-lot order; actual Ix inspection returns a different physical card.' };
}
export type SelectedPhaseRecruitsFixture = ReturnType<typeof createSelectedPhaseRecruitsFixture>;
/** Original card use, normal three-counter return and exact final phase payout.
* The source remains reusable before play for actual GameTable callbacks. */
export function completeSelectedPhaseRecruits(f: SelectedPhaseRecruitsFixture, state = f.game) {
let game = state;
const actions: SelectedPhaseRecruitsStep[] = [];
const act = (actor: string, action: Action) => { game = actSelectedPhaseRecruits(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceSelectedPhaseRecruits(game, until, actions); };
const transition = (run: () => void): SelectedPhaseRecruitsTransition => {
const before = structuredClone(game), from = actions.length; run();
return { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
};
const play = transition(() => { act(f.owner, f.playAction); reach(selectedPhaseRecruitsClean); });
const revival = transition(() => { act(f.owner, f.revivalAction); reach(selectedPhaseRecruitsClean); });
reach(state => state.phase === 4 && selectedPhaseRecruitsClean(state) && (state.revivalOrder
? state.revivalOrder.remaining.length === 1 : state.ready.length === state.players.length - 1));
const revivalPayout = transition(() => {
const step = nextSelectedPhaseRecruits(game); assert.ok(step); act(step.actor, step.action);
reach(state => state.phase === 5 && selectedPhaseRecruitsClean(state));
});
assert.deepEqual(selectedPhaseRecruitsInventory(game), f.originalInventory);
return { game, play, revival, revivalPayout, actions, actionCount: actions.length };
}
@@ -0,0 +1,52 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
import { ownedTech } from '../game/tech-tokens';
import { viewGame } from '../game/engine';
import { phaseModuleEcazInventory as inventory, phaseModuleEcazPlayer as player } from './fixture-phase-module-ecaz';
import { createIndependentPhaseEcazFixture as fixture, resolveIndependentPhaseEcaz as resolve } from './fixture-independent-phase-ecaz';
for (const advanced of [false, true]) for (const lead of ['ecaz', 'ally'] as const)
void test(`mixed independent ${advanced ? 'Advanced Stronghold+Tech' : 'Basic Tech'} ${lead} coalition pays physical fees and withdraws only the card user before winner Tech`, () => {
const f = fixture({ advanced, lead }), before = structuredClone(f.game);
assert.equal(before.ecazTreachery, true); assert.equal(f.originalInventory.length, 50);
assert.deepEqual(before.expansions, ['ix', 'choam', 'ecaz']);
const own = viewGame(before, f.actor).battle!;
assert.equal(own.reinforcements?.blocked, null); assert.equal(own.harassWithdraw?.blocked, null);
const { game, quote, actions } = resolve(f);
assert.deepEqual(f.game, before);
const nonLead = f.actor === f.ecaz ? f.ally : f.ecaz;
assert.equal(quote.winner, f.actor); assert.equal(game.lastBattleContext!.winner, f.actor);
assert.deepEqual(quote.reinforcements, [{ player: f.actor, card: REINFORCEMENTS_CARD, normal: 3, elite: 0 }]);
const returned = lead === 'ecaz' ? { normal: 2, elite: 0 } : { normal: 2, elite: 3 };
const withdrawal = quote.harassWithdraw!.find(entry => entry.player === f.actor)!;
assert.deepEqual(withdrawal.returned, returned);
assert.equal(quote.harassWithdraw!.length, 1);
assert.deepEqual(quote.fixedLosses, [{ owner: f.ecaz, normal: advanced ? 3 : 2, elite: 0 }]);
assert.equal(quote.casualties!.owner, f.ally);
assert.deepEqual(quote.casualties!.options, [{ normal: 1, elite: 0, paidNormal: 0, paidElite: 0 }]);
assert.equal(player(game, f.actor).reserves, player(before, f.actor).reserves - 3 + returned.normal + returned.elite);
assert.equal(player(game, nonLead).reserves, player(before, nonLead).reserves);
assert.equal(player(game, f.ecaz).tanks - player(before, f.ecaz).tanks, (advanced ? 3 : 2) + (lead === 'ecaz' ? 3 : 0));
assert.equal(player(game, f.ally).tanks - player(before, f.ally).tanks, 1 + (lead === 'ally' ? 3 : 0));
assert.equal(player(game, f.ally).elites!.tanks, player(before, f.ally).elites!.tanks);
assert.equal(player(game, f.ecaz).forces[f.location] ?? 0, lead === 'ecaz' ? 0 : 2);
assert.equal(player(game, f.ally).forces[f.location] ?? 0, lead === 'ally' ? 0 : 5);
assert.equal(player(game, f.ally).elites!.forces[f.location] ?? 0, lead === 'ally' ? 0 : 3);
assert.equal(game.techTokens![f.enemyToken].owner, f.actor);
assert.equal(ownedTech(game.techTokens, f.actor).length, 2); assert.equal(ownedTech(game.techTokens, nonLead).length, 1);
const reward = advanced && lead === 'ecaz' ? 1 : 0;
assert.deepEqual(quote.strongholdIncome, reward ? [{ player: f.actor, amount: reward }] : []);
for (const seat of before.players)
assert.equal(player(game, seat.id).spice, seat.spice + (seat.id === f.actor ? reward : 0));
for (const id of ['heighliners', 'axlotl', 'production'] as const) {
assert.equal(game.techTokens![id].spice, before.techTokens![id].spice);
assert.equal(game.techTokens![id].triggeredTurn, before.techTokens![id].triggeredTurn,
'Reserve fees and Harass returns do not trigger shipment or free-revival income');
}
for (const card of [REINFORCEMENTS_CARD, HARASS_WITHDRAW_CARD])
assert.equal(game.discard.filter(discarded => discarded.id === card).length, 1);
assert.ok(actions.some(step => step.action.type === 'traitorCall' && step.action.call === false));
assert.deepEqual(inventory(game), f.originalInventory);
});
@@ -0,0 +1,54 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { registerHooks } from 'node:module';
import { readFileSync } from 'node:fs';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { viewGame } from '../game/engine';
import { createMixedPhaseModulesFixture } from './fixture-mixed-phase-modules';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
},
load(url, context, next) {
if (!url.endsWith('.module.css')) return next(url, context);
const css = readFileSync(new URL(url), 'utf8');
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true };
},
});
const { GameTable } = await import('../components/game-table');
aliases.deregister();
// Stateful components render through React SSR. Recorded public actions prove
// authoritative selection/Start; these renders do not claim browser clicks.
void test('original independent-card and phase controls remain selected through Basic and Advanced preview Start', () => {
for (const advanced of [false, true]) {
const f = createMixedPhaseModulesFixture({ factions: ['ixians', 'ecaz', 'moritani'], expansions: ['ix', 'ecaz'],
advanced, tech: true, strongholds: advanced, independent: true });
const render = (game: typeof f.game) => renderToStaticMarkup(createElement(GameTable, {
game: viewGame(game, game.host), busy: false, send: async () => {}, onExit() {},
}));
const html = render(f.lobby);
const variant = [...html.matchAll(/<label\b[^>]*>([\s\S]*?)<\/label>/g)]
.find(([, children]) => children.includes('Ecaz Treachery Cards'));
assert.ok(variant, 'The lobby exposes the original optional independent-card control');
assert.match(variant[1], /<input\b[^>]*checked=""/);
assert.match(variant[1], /Recruits, Reinforcements, Harass &amp; Withdraw/);
assert.match(html, /Tech tokens · optional/);
const label = advanced ? 'Begin Advanced preview' : 'Begin game';
const begin = [...html.matchAll(/<button\b([^>]*)>([\s\S]*?)<\/button>/g)]
.find(([, , children]) => children.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim() === label);
assert.ok(begin, 'The ready host has its original Start control');
assert.doesNotMatch(begin[1], /(?:^|\s)disabled(?:=""|\s|$)/);
assert.equal(f.lobby.lobbyOptions?.ecazTreachery, true);
assert.ok(f.actions.some(step => step.actor === f.initial.host && step.action.type === 'moduleOption' &&
step.action.module === 'ecazTreachery' && step.action.enabled === true));
assert.ok(f.actions.some(step => step.action.type === 'start' && !!step.action.advancedPreview === advanced));
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.ecazTreachery, true);
const playing = render(f.game);
assert.match(playing, /Heighliners/);
if (advanced) assert.match(playing, /Inspect Stronghold Cards/);
}
});
@@ -0,0 +1,103 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame } from '../game/engine';
import { botActions } from '../game/bots';
import { MOBILE_STRONGHOLD } from '../game/board';
import { treacheryDeck } from '../game/cards';
import { ecazTreacheryCards } from '../game/ecaz-cards';
import {
createMixedPhaseModulesFixture, createMixedPhaseModulesProgramme,
mixedPhaseModulesClean, mixedPhaseModulesInventory, mixedPhaseModulesPlayer as player,
type MixedPhaseModulesOptions,
} from './fixture-mixed-phase-modules';
function entry(options: MixedPhaseModulesOptions) {
const f = createMixedPhaseModulesFixture({ ...options, independent: true });
assert.equal(f.initial.status, 'lobby'); assert.equal(f.initial.ecazTreachery, undefined);
const selected = f.actions.findIndex(step => step.action.type === 'moduleOption' &&
step.action.module === 'ecazTreachery' && step.action.enabled === true);
const ready = f.actions.findIndex(step => step.action.type === 'ready');
assert.ok(selected >= 0 && selected < ready);
assert.equal(f.actions[selected].actor, f.initial.host);
assert.equal(f.lobby.lobbyOptions?.ecazTreachery, true);
assert.ok(f.lobby.players.every(seat => seat.ready));
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.ecazTreachery, true);
assert.equal(f.game.status, 'playing'); assert.equal(f.game.turn, 1); assert.equal(f.game.phase, 1);
assert.equal(f.game.ecazTreachery, true); assert.equal(f.game.advanced, !!options.advanced);
assert.equal(!!f.game.techTokens, !!options.tech); assert.equal(!!f.game.strongholdCards, !!options.strongholds);
assert.deepEqual(f.game.expansions, options.expansions);
assert.ok(!f.game.homeworlds && !f.game.leaderSkills && !f.game.nexusCards &&
!f.game.discoveryEnabled && !f.game.semutaPreview);
assert.equal(f.originalInventory.length, treacheryDeck(options.expansions).length + 3);
assert.ok(ecazTreacheryCards().every(card => f.originalInventory.includes(card.id)));
assert.deepEqual(mixedPhaseModulesInventory(f.game), f.originalInventory);
assert.ok(f.afterSetup.players.every(seat => seat.hand.length > 0), 'Original native setup deals the actual starting cards');
assert.equal(f.sourceActionCount, f.actions.length); assert.deepEqual(f.staging, []);
return f;
}
void test('public Basic independent cards support paired E3 Tech and bare mixed Ecaz without incidental phase modules', () => {
entry({ factions: ['ecaz', 'moritani', 'guild'], expansions: ['ecaz'], tech: true });
entry({ factions: ['ecaz', 'ixians', 'tleilaxu'], expansions: ['ix', 'choam', 'ecaz'] });
});
void test('public Advanced independent cards support mixed Strongholds and classic-only unused selected decks with both phase modules', () => {
entry({ factions: ['ecaz', 'choam', 'guild'], expansions: ['choam', 'ecaz'], advanced: true, strongholds: true });
entry({ factions: ['atreides', 'emperor', 'guild'], expansions: ['ix', 'choam', 'ecaz'], advanced: true,
tech: true, strongholds: true });
});
for (const advanced of [false, true]) void test(`public all-six independent-card ${advanced ? 'Advanced Tech and Strongholds' : 'Basic Tech'} preserves its earned phase payouts and physical cards`, () => {
const f = createMixedPhaseModulesProgramme({ advanced, independent: true });
assert.equal(f.independent, true); assert.equal(f.setup.ecazTreachery, true);
assert.equal(f.setup.deck.length, 50); assert.equal(f.setup.richeseCache?.length, 10);
assert.equal(f.originalInventory.length, 60);
assert.equal(f.setup.techTokens?.heighliners.owner, 'ixians');
assert.equal(f.setup.techTokens?.axlotl.owner, 'tleilaxu');
assert.ok(f.actions.some(step => step.action.type === 'moduleOption' && step.action.module === 'ecazTreachery'));
assert.ok(f.actions.some(step => step.actor === 'ixians' && step.action.type === 'decision' && typeof step.action.card === 'string'));
assert.ok(f.afterSetup.players.every(seat => seat.hand.length > 0));
assert.equal(player(f.choamShipment.after, 'choam').forces[f.choamLocation], 1);
assert.equal(player(f.choamShipment.before, 'choam').spice - player(f.choamShipment.after, 'choam').spice, 1);
assert.equal(player(f.moritaniShipment.after, 'moritani').forces[f.casualtyLocation], 1);
assert.equal(player(f.moritaniShipment.before, 'moritani').spice - player(f.moritaniShipment.after, 'moritani').spice, 2);
for (const receipt of [f.choamShipment, f.moritaniShipment]) {
assert.equal(receipt.after.techTokens?.heighliners.spice, 1);
assert.equal(receipt.after.techTokens?.heighliners.triggeredTurn, 1);
assert.equal(player(receipt.after, 'ixians').spice, player(receipt.before, 'ixians').spice);
}
assert.equal(f.shipmentPayout.before.phase, 5); assert.equal(f.shipmentPayout.after.phase, 6);
assert.equal(f.shipmentPayout.after.techTokens?.heighliners.spice, 0);
assert.equal(player(f.shipmentPayout.after, 'ixians').spice - player(f.shipmentPayout.before, 'ixians').spice, 1);
if (advanced) {
assert.equal(f.mentat.before.strongholdCards?.claimedTurn, 0);
assert.equal(f.mentat.after.strongholdCards?.claimedTurn, 1);
assert.equal(f.mentat.after.strongholdCards?.owners.carthag, 'moritani');
assert.equal(f.mentat.after.strongholdCards?.owners.sietch_tabr, 'choam');
assert.equal(f.mentat.after.strongholdCards?.owners[MOBILE_STRONGHOLD], 'ixians');
}
assert.equal(f.storm.before.stormMovementSource?.kind, advanced ? 'card' : 'dials');
assert.equal(f.storm.before.stormPending, advanced ? 1 : 2);
assert.equal(player(f.storm.after, 'moritani').tanks, 1);
assert.equal(player(f.storm.after, 'moritani').forces[f.casualtyLocation], undefined);
assert.deepEqual(f.revival.steps[0], { actor: 'moritani', action: { type: 'revive', amount: 1, elite: 0 } });
if (advanced) assert.equal(f.revival.before.revivalOrder?.remaining[0], 'moritani');
assert.equal(player(f.revival.after, 'moritani').tanks, 0); assert.equal(player(f.revival.after, 'moritani').revived, 1);
assert.equal(player(f.revival.after, 'moritani').spice, player(f.revival.before, 'moritani').spice);
assert.equal(player(f.revival.after, 'tleilaxu').spice - player(f.revival.before, 'tleilaxu').spice, 1);
assert.equal(f.revival.after.techTokens?.axlotl.spice, 1);
assert.equal(f.revivalPayout.after.techTokens?.axlotl.spice, 0);
assert.equal(player(f.revivalPayout.after, 'tleilaxu').spice - player(f.revivalPayout.before, 'tleilaxu').spice, 1);
assert.equal(f.game.phase, 5); assert.equal(f.game.turn, 2); assert.ok(mixedPhaseModulesClean(f.game));
assert.deepEqual(mixedPhaseModulesInventory(f.game), f.originalInventory); assert.deepEqual(f.staging, []);
assert.ok(!f.game.homeworlds && !f.game.leaderSkills && !f.game.nexusCards && !f.game.discoveryEnabled);
// Recruits acquisition and use have their own native lane. This programme
// consumes original phase choices and never selects a new card effect.
assert.ok(!f.actions.some(step => step.action.type === 'card' && step.action.card === 'ecaz-recruits'));
if (advanced) {
const actor = f.game.active; assert.ok(actor);
const view = viewGame(f.game, actor); view.players.find(seat => seat.id === actor)!.bot = 'Easy';
const candidate = botActions(view).find(action => action.type === 'endMovement'); assert.ok(candidate);
assert.doesNotThrow(() => applyAction(f.game, actor, candidate));
}
});
+104
View File
@@ -0,0 +1,104 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
nativeFactionTechProfile as tech, selectedFactionStrongholdProfile as strongholds,
selectedIndependentCardsProfile as independent, type FactionModuleProfile,
} from '../game/faction-module-profile';
import { createStrongholdCards } from '../game/stronghold-cards';
import { createTechTokens } from '../game/tech-tokens';
function profile(factions: readonly string[] = ['ecaz', 'moritani', 'guild'],
expansions: readonly string[] = ['ecaz'], extra: Partial<FactionModuleProfile> = {}): FactionModuleProfile {
return { advanced: true, expansions, players: factions.map(faction => ({ faction })),
ecazTreachery: true, techTokens: createTechTokens(), ...extra };
}
void test('selected independent cards retain original Tech across paired, mixed and all-three-family rosters in both modes', () => {
for (const advanced of [false, true]) for (const game of [
profile(undefined, undefined, { advanced }),
profile(['ecaz', 'ixians', 'emperor'], ['ecaz', 'ix'], { advanced }),
profile(['moritani', 'choam', 'guild'], ['choam', 'ecaz'], { advanced }),
profile(['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani'], ['ix', 'choam', 'ecaz'], { advanced }),
]) {
const before = structuredClone(game);
assert.equal(independent(game), true); assert.deepEqual(game, before);
assert.equal(tech(game), false, 'The card-free Tech wrapper must keep its existing scope');
assert.equal(strongholds(game), false, 'The card-free Stronghold wrapper must keep its existing scope');
}
});
void test('classic-only and unused selected families remain independent from actual seated native factions', () => {
for (const decks of [['ix'], ['choam'], ['ecaz'], ['ix', 'choam', 'ecaz']])
assert.equal(independent(profile(['atreides', 'emperor', 'guild'], decks)), true);
assert.equal(independent(profile(['ixians', 'guild', 'emperor'], ['ix', 'choam', 'ecaz'])), true);
for (const game of [
profile(['atreides', 'emperor', 'guild'], []),
profile(['ecaz', 'guild', 'emperor'], ['ix']),
profile(['ecaz', 'guild', 'emperor'], ['ecaz', 'ecaz']),
profile(['ecaz', 'guild', 'emperor'], ['ecaz', 'unknown']),
profile(['unknown', 'guild', 'emperor']),
]) assert.equal(independent(game), false);
});
void test('the exact independent card selection permits bare mixed Basic play and optional original phase modules', () => {
assert.equal(independent(profile(undefined, undefined, { techTokens: null, strongholdCards: null })), true);
assert.equal(independent(profile(['ecaz', 'ixians', 'guild'], ['ecaz', 'ix'], {
advanced: false, techTokens: null, strongholdCards: null,
})), true);
for (const ecazTreachery of [undefined, false, 'true', 1])
assert.equal(independent(profile(undefined, undefined, { ecazTreachery })), false);
assert.equal(independent(profile(undefined, undefined, { techTokens: null, strongholdCards: createStrongholdCards() })), true);
assert.equal(independent(profile(undefined, undefined, { strongholdCards: createStrongholdCards() })), true);
});
void test('Tech keeps three-six seats and Strongholds two-six Advanced without inventing a Basic card mode', () => {
const two = profile(['ecaz', 'ixians'], ['ecaz', 'ix']);
assert.equal(independent(two), false);
assert.equal(independent({ ...two, techTokens: null, strongholdCards: createStrongholdCards() }), true);
assert.equal(independent({ ...two, techTokens: null, strongholdCards: createStrongholdCards(), advanced: false }), false);
assert.equal(independent(profile(undefined, undefined, { advanced: false })), true);
assert.equal(independent(profile(undefined, undefined, { advanced: false, strongholdCards: createStrongholdCards() })), false);
assert.equal(independent(profile(['ecaz'])), false);
assert.equal(independent(profile(['ecaz', 'ecaz', 'guild'])), false);
assert.equal(independent(profile(['ecaz', 'moritani', 'guild', 'atreides', 'emperor', 'ixians', 'choam'], ['ix', 'choam', 'ecaz'])), false);
});
void test('unrelated modules, active Sandtrout and reaction-preview selectors cannot enter the independent phase scope', () => {
for (const field of [
'homeworlds', 'leaderSkills', 'nexusCards', 'discoveryEnabled', 'discoveries', 'discoveryStash', 'greatMaker',
'sandtrout', 'spiceBankerIncomePreview', 'semutaPreview', 'advancedPreview', 'kullPreview', 'nexusKullPreview',
'guildBetrayalPreview', 'richeseBetrayalPreview', 'nexusIxianReplacementPreview', 'nexusIxianBetrayalPreview',
'nexusHarkonnenBetrayalPreview',
] as const) assert.equal(independent({ ...profile(), [field]: true }), false, field);
});
void test('canonical native runtime stamps require their actual faction and Advanced assassination while captured-target Harkonnen is permitted', () => {
assert.equal(independent(profile(undefined, undefined, { ecazOccupyPreview: true, moritaniAssassinatePreview: true })), true);
assert.equal(independent(profile(['ecaz', 'ixians', 'guild'], ['ecaz', 'ix'], { advanced: false, ecazOccupyPreview: true })), true);
assert.equal(independent(profile(['moritani', 'harkonnen', 'guild'], ['ecaz'], { moritaniAssassinatePreview: true })), true);
for (const game of [
profile(['ixians', 'guild', 'emperor'], ['ix'], { ecazOccupyPreview: true }),
profile(['ecaz', 'guild', 'emperor'], ['ecaz'], { moritaniAssassinatePreview: true }),
profile(undefined, undefined, { advanced: false, moritaniAssassinatePreview: true }),
profile(undefined, undefined, { ecazOccupyPreview: 'forged' }),
profile(undefined, undefined, { moritaniAssassinatePreview: 'forged' }),
]) assert.equal(independent(game), false);
});
void test('default phase wrappers and explicit Semuta retain their previous admission rather than inherit independent cards', () => {
const bare = { ...profile(), ecazTreachery: undefined };
assert.equal(tech(bare), true); assert.equal(strongholds(bare), true); assert.equal(tech(bare, true), false);
const semuta = profile(['richese', 'choam', 'guild'], ['choam'], { ecazTreachery: undefined, semutaPreview: true });
assert.equal(tech(semuta), false); assert.equal(tech(semuta, true), true); assert.equal(strongholds(semuta), false);
assert.equal(independent(semuta), false);
const combined = { ...semuta, ecazTreachery: true };
assert.equal(independent(combined), false); assert.equal(tech(combined, true), false);
});
void test('the predicate accepts frozen public configuration without copying or mutating it and rejects malformed roster shapes', () => {
const game = profile(); Object.freeze(game.expansions); game.players.forEach(Object.freeze); Object.freeze(game.players); Object.freeze(game);
assert.equal(independent(game), true);
for (const value of [null, {}, { ...profile(), players: 'ecaz,moritani,guild' },
{ ...profile(), players: [{ faction: 'ecaz' }, null] }, { ...profile(), expansions: 'ecaz' }])
assert.equal(independent(value as unknown as FactionModuleProfile), false);
});
@@ -0,0 +1,73 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame } from '../game/engine';
import { RECRUITS_CARD_ID } from '../game/recruits';
import {
completeSelectedPhaseRecruits, createSelectedPhaseRecruitsFixture,
selectedPhaseRecruitsInventory as inventory, selectedPhaseRecruitsPlayer as player,
} from './fixture-selected-phase-recruits';
for (const advanced of [false, true]) {
void test(`public all-six-native selected-family ${advanced ? 'Advanced Stronghold and Tech' : 'Basic Tech'} Recruits earns an extra free return and separate native/token income`, () => {
const f = createSelectedPhaseRecruitsFixture({ advanced });
assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup');
assert.deepEqual(f.setup.players.map(seat => seat.faction), ['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani']);
assert.deepEqual(f.setup.expansions, ['ix', 'choam', 'ecaz']);
assert.ok(f.setup.ecazTreachery && f.setup.techTokens);
assert.equal(!!f.setup.strongholdCards, advanced);
assert.ok(f.actions.some(step => step.action.type === 'moduleOption' && step.action.module === 'ecazTreachery' && step.action.enabled === true));
assert.ok(!f.game.homeworlds && !f.game.leaderSkills && !f.game.nexusCards && !f.game.discoveryEnabled && !f.game.semutaPreview);
assert.equal(f.originalInventory.length, 60); assert.deepEqual(inventory(f.game), f.originalInventory);
assert.deepEqual(f.staging, []); assert.equal(f.sourceActionCount, f.sourceActions.length);
assert.equal(f.beforeAuctionOrder.phase, 1); assert.equal(f.unopenedAuctionOrder.card, RECRUITS_CARD_ID);
assert.equal(f.acquisition.before.auction?.index, 0);
assert.equal(f.acquisition.before.auction?.cards[0].id, RECRUITS_CARD_ID);
assert.equal(f.acquisition.bid.actor, f.owner); assert.equal(f.acquisition.bid.action.amount, 1);
assert.equal(player(f.acquisition.before, f.owner).spice - player(f.acquisition.after, f.owner).spice, 1);
assert.ok(player(f.acquisition.after, f.owner).hand.some(card => card.id === RECRUITS_CARD_ID));
assert.equal(player(f.shipment.after, f.owner).forces[f.casualtyLocation], 3);
assert.equal(player(f.shipment.before, f.owner).spice - player(f.shipment.after, f.owner).spice, 6);
assert.equal(f.storm.before.stormMovementSource?.kind, advanced ? 'card' : 'dials');
assert.equal(player(f.storm.after, f.owner).forces[f.casualtyLocation], undefined);
assert.equal(player(f.game, f.owner).tanks, 3); assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 4);
const ordinary = viewGame(f.game, f.owner).revival;
assert.equal(ordinary.freeRemaining, 2); assert.equal(ordinary.limit, 3);
if (advanced) {
assert.equal(f.game.revivalOrder?.remaining[0], f.owner);
assert.equal(f.mentat.after.strongholdCards?.claimedTurn, 1);
assert.equal(f.mentat.after.strongholdCards?.owners.carthag, f.owner);
}
const before = structuredClone(f.game);
const result = completeSelectedPhaseRecruits(f);
assert.deepEqual(f.game, before, 'Completing the native programme retains its reusable pre-play source');
assert.deepEqual(result.play.steps[0], { actor: f.owner, action: f.playAction });
assert.equal(result.play.after.recruits?.turn, 2);
assert.equal(player(result.play.after, f.owner).hand.some(card => card.id === RECRUITS_CARD_ID), false);
assert.equal(result.play.after.discard.filter(card => card.id === RECRUITS_CARD_ID).length, 1);
const doubled = viewGame(result.play.after, f.owner).revival;
assert.equal(doubled.freeRemaining, 4); assert.equal(doubled.limit, 7);
assert.equal(player(result.play.after, f.owner).tanks, 3);
assert.equal(player(result.play.after, f.owner).spice, player(f.game, f.owner).spice);
assert.throws(() => applyAction(result.play.after, f.owner, f.playAction));
assert.deepEqual(result.revival.steps[0], { actor: f.owner, action: f.revivalAction });
const returned = player(result.revival.after, f.owner), prior = player(result.revival.before, f.owner);
assert.equal(returned.tanks, 0); assert.equal(returned.reserves - prior.reserves, 3);
assert.equal(returned.revived, 3); assert.equal(returned.freeForcesRevived, 3);
assert.equal(returned.spice, prior.spice, 'The third counter uses the additional free rate rather than a paid return');
assert.equal(player(result.revival.after, f.bankOwner).spice - player(result.revival.before, f.bankOwner).spice, 1,
'Native Tleilaxu free-revival income is one separate bank spice');
assert.equal(result.revival.after.techTokens?.axlotl.owner, f.bankOwner);
assert.equal(result.revival.after.techTokens?.axlotl.spice, 1);
assert.equal(result.revival.after.techTokens?.axlotl.triggeredTurn, 2);
assert.equal(result.revivalPayout.after.techTokens?.axlotl.spice, 0);
assert.equal(player(result.revivalPayout.after, f.bankOwner).spice - player(result.revivalPayout.before, f.bankOwner).spice, 1,
'Original Axlotl payout waits for the actual Revival phase boundary');
assert.equal(result.game.turn, 2); assert.equal(result.game.phase, 5);
assert.deepEqual(inventory(result.game), f.originalInventory);
assert.equal(result.game.discard.filter(card => card.id === RECRUITS_CARD_ID).length, 1);
for (const seat of result.game.players)
assert.equal(seat.reserves + seat.tanks + Object.values(seat.forces).reduce((sum, amount) => sum + amount, 0), 20);
});
}