Compose independent cards with Discovery and optional Nexus

Connect original selected-deck Discovery setup, optional Nexus/World/Skills/phase modules and physical card consumers through shared public capabilities. Retain Cunning revealed-location arrival, Sandtrout suppression, paid nested R/Harass and actual later Recruits revival. Preserve native trained-capture and unrelated-preview boundaries.

Validation: frozen 15-file118/118, types/lint/build and six finite original-action/rendered-control programmes; four minimal legal policies. External smoke corrections changed no application source. Existing persistent notices also rechecked23/23. Full combinations, assurance and deployed acceptance remain open; no saved-game, restart or deployment operation.
This commit is contained in:
Quintine committed 2026-10-18 11:48:45 +11:00
1 parent b4157aaa9b
commit 677ad7aa3c
21 files changed
+1000 -37

No files matched your search

+31 -1
View File
@@ -16,13 +16,43 @@ open mode gates.
## Current checkpoint and work
**Independent cards with Discovery — 18 October. Prototyped / Partial.**
Fresh classic and selected-deck tables now combine independent36/38/50 with
Discovery7/8, with or without Nexus12. Original World/all14/Tech3+/Advanced
Strongholds remain optional. Real host/setup choices retain native pieces;
transient Sandtrout or Maker frames no longer remove unrelated capabilities.
The trained actual-E3/Harkonnen and unrelated-preview gates remain.
Three disjoint fixture/review lanes plus owner integration pass frozen
**15 files118/118**, types/lint/build and six finite original-action/control
programmes. Actual reveal/free-entry/Cunning uses97 Basic or359 Advanced
three-family World/all14/phase actions; original Sandtrout155. Basic nested
paid-card battles use75+4 without Nexus or78+5 with it. Selected50 World fee,
Sardaukar return and earned Recruits revival use149+8+52. Four minimal policies
retain legal Cunning and source-bearing positive Reinforcements plans; no tuning.
Bounded review repaired two no-Nexus Duke quote admissions. Fixture feedback
corrected Fremen's printed Hiereg knowledge and the existing Recruits view name.
The external control programme corrected its entry label and rendered the shared
reserve selector from the actual quote; application source stayed unchanged.
Framework aliases/CSS stubs mean no visual/browser/deployed acceptance. Broader
Nexus/Discovery/Semuta interactions, full modes and final assurance remain open.
No saved room/database, reset, restart or deployment.
[Exact source, proof and limits](NEXUS_CARD_RULES.md#independent-cards-with-discovery--18-october-2026).
The notice follow-up also rechecked the existing persistent room/seat/action
controls: **23/23** focused tests pass. Room confirmations still wait for Continue;
shared toasts have Dismiss and no automatic timeout. Their earlier local browser
proof remains dated; [deployed notice verification remains open](READABLE_NOTICES.md).
**Native Fremen Nexus modules — 18 October. Prototyped / Partial.**
Cunning and Betrayal now retain the original registered Nexus roster and selected
modules. Actual drawn Cunning, original Karama allowance/cancellation and the
typed post-negotiation ride keep their source and normal allowances. A living
native Sandmaster may collect one earned destination spice. Original HMS entry
requires its actual pointer to target the selected desert; Discovery destinations
have focused quote coverage, with their actual arrival smoke still due.
had focused quote coverage here; the later independent Discovery checkpoint
above adds actual arrival proof.
Great Maker reserve riding now respects the predeclared worm restriction under
the explicit source composition. Votes, original casualties and Nexus continue;
+9
View File
@@ -4,6 +4,15 @@ Updated 17 October 2026. This connects the physical card to battle plans,
private inspections, force returns, controls, AI and saved continuation. It does
not certify the complete independent card variant or a complete rules mode.
## Independent Discovery composition — 18 October 2026
Original selected36/38/50 cards now compose with Discovery7/8 and optional
Nexus, World/all14/phase modules. Real Jacurutu reveal/free entry and paid
shipment precede purchased R/Harass settlement; typed native fees and returns
retain existing policies. The [canonical source-bound programme](NEXUS_CARD_RULES.md#independent-cards-with-discovery--18-october-2026)
records actual later Recruits revival, focused proof and remaining limits.
This prototype does not certify full card/module combinations or complete modes.
## Original Skills/phase composition — 17 October 2026
Supported classic, selected E1/E2 and standalone/paired E3 all14/card profiles
+16
View File
@@ -1,5 +1,21 @@
# Dune implementation status
## 18 October 2026 — Independent cards with Discovery
Fresh selected36/38/50 and original Discovery7/8 now compose with or without
Nexus12 and original optional World/all14/phase modules. Shared public
capabilities retain native setup and card consumers across transient encounters.
Actual reveal/free entry/Cunning, original Sandtrout suppression, paid nested
R/Harass, typed World returns and earned Recruits revival have source programmes.
Bounded review corrected two no-Nexus Duke quote admissions while preserving
trained native-E3/Harkonnen exclusion. No new numerical rule.
Frozen15-file118/118, types/lint/build and six original-action/rendered-control
programmes pass, with four minimal legal policies. External smoke label/selected-
control assumptions were corrected against unchanged application source.
[Canonical source, action counts and limits](NEXUS_CARD_RULES.md#independent-cards-with-discovery--18-october-2026).
No saved-room/reset/restart/deployment or complete-mode assurance claim.
## 18 October 2026 — Native Fremen Nexus modules
Original registered Nexus rosters now retain Cunning/Betrayal with selected
+66 -2
View File
@@ -2,6 +2,70 @@
Original source audit, 10 September 2026, with dated bounded runtime follow-ups below. This document establishes the printed component inventory, source protocol and implementation boundaries; none lifts expansion release gates. The ordinary worm-triggered alliance Nexus already in the engine is distinct from this optional card module.
## Independent cards with Discovery — 18 October 2026
**Prototyped / Partial.** The independent three-card variant now composes with
original Discovery setup for registered unique two-through-six-seat rosters:
classics or natives from zero through three selected Ix/CHOAM/Ecaz families.
A selected deck does not require its native seat. Nexus is optional, as are
original Homeworlds, all14 Skills, Tech3+ and Advanced Strongholds. The printed
independence of the four E3 variants is the existing authority in
[the faction source audit](EXPANSION_FACTIONS_PROTOTYPE.md#source-and-deck-boundary).
No new numerical or timing interpretation is introduced.
The shared public configuration capability admits the original Discovery7/eight
token inventory and selected36/38/50 Treachery stock. Fresh setup still checks
original undealt components and native force/card inventories. With Nexus it
initializes original twelve-card stock and actual native reaction state; without
Nexus it creates none of those effects. Original trained E3/Harkonnen and
unrelated-preview exclusions remain. Runtime capability no longer disappears
merely because Sandtrout, a token, stash or Maker frame exists. Specific card,
occurrence, response, reserve-fee, physical allocation and native arrival checks
remain authoritative; this does not certify every transient interaction.
Original action programmes now cover paid finder shipment, actual supply-token
placement, private inspection/public reveal and next-turn free entry before
native Fremen Cunning lands in revealed Cistern. Native Fremen retains printed
Hiereg knowledge before anyone inspects it. A separate original E1 Sandtrout
breaks an earned alliance, suppresses its next worm, doubles its immediate land
replacement and retains the unused Fremen card. No false Nexus draw is added.
A real Jacurutu reveal/free entry then paid enemy shipment produces a nested
battle. Actually purchased Reinforcements pays three original reserves before
Harass returns the two undialed counters. Advanced native Emperor fees use
Kaitain normals; Sardaukar return to Salusa. Real fee/dial casualties then fund
next-turn Recruits, not a staged Tank count. Same-plan fee-before-return and
other previously recorded policies remain provisional. Two no-Nexus Duke pure
quote guards now admit the untrained Advanced Ecaz/Harkonnen composition;
trained actual-E3/Harkonnen stays excluded. This is quote evidence, not a new
complete acquisition/death/revival programme.
Evidence: [genuine host entry](../tests/independent-discovery-nexus-entry.test.ts),
[actual Cunning/Sandtrout](../tests/independent-discovery-cards.test.ts),
[nested physical card battles/revival](../tests/independent-discovery-battles.test.ts),
[Duke acquisition](../tests/ecaz-duke-acquisition.test.ts) and
[revival quote](../tests/ecaz-duke-revival.test.ts). One owner froze three
helper lanes and shared integration: **15 files118/118**, types/lint/build and
six finite original-action/rendered-control programmes pass. Basic/Advanced
Cunning97/359 actions; Sandtrout155; Basic no-Nexus75+4 and Nexus78+5 battle;
selected50 World battle149+8 followed by52 original Revival actions. Four
policies each retain legal Cunning offer/ride and source-bearing positive
Reinforcements plans. No AI refinement/calibration.
Private proof is outside Git at
`/home/quintine/.local/share/dune/checkpoints/20261018-independent-discovery/`:
`combined-proof.json` binds the first three passed frozen steps and corrected
runtime-only report to unchanged tree `2aa05183becbcf517d5b60ae32e5c311dcec36a35befc5a6343b6ad76f80b0ea`
at base `b4157aaa9bb89c9643704985eb98b0a630881ded`.
The external programme first expected the wrong entry label, then expected the
reserve selector before selecting its card. Its corrected actual-table render
and shared selector from the real quote pass without application changes.
`runtime-smoke.json` records counts and limitations; HTML, per-file hashes and
post-proof prose overlay accompany it. Framework aliases/CSS stubs mean no
visual/browser/deployed acceptance. Full combinations, Great Maker/card bands,
Semuta, complete modes and comprehensive assurance remain due. No saved-game,
reset, restart or deployment operation occurred.
## Native Fremen Nexus modules — 18 October 2026
**Prototyped / Partial.** Native Fremen Cunning and Betrayal now use original
@@ -15,8 +79,8 @@ pile quote; a declined/omitted collection still rides.
Revealed Discovery destinations and source-parent HMS geometry share the public
human/bot/server destination calculation. HMS has genuine first-Storm placement
and typed arrival proof; Discovery has focused pure quote evidence, with actual
arrival acceptance still due. Native movement Betrayal leaves ordinary range1
and typed arrival proof. Discovery had focused quotes at this earlier checkpoint;
the independent Discovery checkpoint above adds actual arrival evidence. Native movement Betrayal leaves ordinary range1
and earned city range3 intact. Predeclared worm prevention also suppresses Great
Maker reserve riding under the explicit source composition, while votes,
casualties and Nexus continue. A completed Maker does not block a later ordinary
+9
View File
@@ -16,6 +16,15 @@ fixes the exact card. Harass & Withdraw may share the plan under the
Original or copied Stone Burner composes in clean selected Ix/CHOAM families
and the existing native Advanced Occupy envelope. Other profile boundaries remain.
## Independent Discovery composition — 18 October 2026
Original selected36/38/50 cards now compose with Discovery7/8 and optional
Nexus, World/all14/phase modules. Real Jacurutu reveal/free entry and paid
shipment precede purchased R/Harass settlement; typed native fees and returns
retain existing policies. The [canonical source-bound programme](NEXUS_CARD_RULES.md#independent-cards-with-discovery--18-october-2026)
records actual later Recruits revival, focused proof and remaining limits.
This prototype does not certify full card/module combinations or complete modes.
## Independent World/Nexus temporary roles — 18 October 2026
Fresh classic independent36 + Homeworlds + Nexus12 now composes in Basic and
+1
View File
@@ -762,6 +762,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP
| 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 variant independence composes registered actual selected-family all14 Basic/Advanced2–6, including mixed E3/E1/E2, unused decks, original Worlds and Tech3+/Advanced Strongholds2+. No Nexus/exact E3 family/card dependency is invented. Original36/38/50, separate cache and native setup/custody remain. Bare mixed E3 World/phase cards, original Nexus/Discovery/Semuta, Advanced role, shared Duke and effect-specific limits stay separate. Fees/H/committed-only Suk, own-World bans and separate Banker/Tech remain. No new numeric rule/save retrofit. | [Current registered native scope](LEADER_SKILLS_RUNTIME.md#registered-native-e3-selected-decks--18-october-2026), [physical sources](ECAZ_TREACHERY_RULES.md) |
| Independent Treachery / Discovery / optional Nexus | Existing printed E3 variant independence admits fresh registered classic/selected0–3-family independent36/38/50 plus Discovery7/8, optionally Nexus12/World/all14/phase. Native setup and physical card rules remain original; transient encounters do not remove unrelated capabilities. Actual Cunning nested arrival, Sandtrout suppression, paid R/Harass and earned Recruits have bounded programmes. No new numerical/timing ruling; trained actual-E3/Harkonnen, unrelated previews, full modes and broader interactions remain guarded. | [Source-bound proof and limits](NEXUS_CARD_RULES.md#independent-cards-with-discovery--18-october-2026) |
| Classic independent Treachery / Nexus | Printed independent-variant authority composes fresh classic2–6 Basic/Advanced independent36/Nexus12, optionally all14, through one public configuration predicate and original setup. Recruits changes ordinary rates/total7, not fixed Tleilaxu group price/free classification. Cunning keeps temporary ordinary counters, separate Reinforcements reserve fees, actual Harass returns and original cancellation. No new numeric rule or played-save conversion. Native selected families, World/phase/Discovery/Semuta, reaction-preview and other effect-specific bounds remain separate; inherited provisional timings are not promoted to publisher rulings. | [Source and exact native proof](NEXUS_CARD_RULES.md#classic-independent-treachery-composition--18-october-2026) |
| Classic independent native Nexus reactions | Fresh independent36/Nexus12 optional14 initializes the existing Ixian purchase and actual Harkonnen declared-Traitor effect stamps; played games remain unchanged. Public selection binds canonical faces and the original census, with a new independent marker only where selected. Under standing choose-recommended authority, inherit the existing bounded native-counter-first/neutral acknowledgement and replacement-before-Harkonnen-bonus conventions, visibly provisional, not new publisher or universal rulings. Personal/allied source, real Mentat replacement and original paid/card/force mechanics remain. Other native/module/reaction envelopes stay separate. | [Current source and actual controls](NEXUS_CARD_RULES.md#classic-selected-card-reactions--18-october-2026) |
| Classic Nexus core / funded card shipments | Original Nexus independence admits fresh classic2–6 Basic/Advanced base33 or selected independent36, optionally all14, without a three-card dependency. Initialize actual Guild/Harkonnen/Ixian reaction states only at fresh setup. Original signed Cunning reserve/cross/return and Richese one-counter grants retain native counters, funding and physical continuation through the same Guild fee gate. One Guild singleton cannot simultaneously fund Cunning and rival Use; actual wrong-face Pass remains neutral. Actual third-party Bureaucrat sees the real recipient and printed payer exclusions. No new numerical/universal ruling, played reflag or wider module opening; inherited timing stays provisional. | [Current source and exact proof](NEXUS_CARD_RULES.md#classic-nexus-core-and-funded-card-shipments--18-october-2026) |
+2 -1
View File
@@ -1,5 +1,5 @@
import { FACTIONS } from './catalog';
import { mixedE3NexusRosterProfile, type FactionModuleProfile } from './faction-module-profile';
import { independentDiscoveryProfile, mixedE3NexusRosterProfile, type FactionModuleProfile } from './faction-module-profile';
import type { LeaderSkillProfile } from './leader-skill-profile';
/** Public unused-lobby admission only. The original initializer owns physical
@@ -16,6 +16,7 @@ export function homeworldDiscoveryProfile(game: FactionModuleProfile): boolean {
/** The same roster/module contract during play; live Discovery frames are expected. */
export function discoveryModeSupported(game: FactionModuleProfile): boolean {
if (independentDiscoveryProfile(game)) return true;
if (game.nexusCards) return standaloneE3DiscoveryNexusProfile(game) || singleE1E2DiscoveryNexusProfile(game) ||
mixedE3DiscoveryNexusProfile(game) || mixedE1E2DiscoveryNexusProfile(game) || pairedE3DiscoveryNexusProfile(game);
if (typeof game.advanced !== 'boolean' || game.discoveryEnabled !== true ||
+2 -2
View File
@@ -1,7 +1,7 @@
import type { Game } from './engine';
import type { FactionId } from './catalog';
import { acquireDuke, DUKE_VIDAL_ID, type DukeState } from './duke-vidal';
import { nexusCoreProfile, type FactionModuleProfile } from './faction-module-profile';
import { independentDiscoveryProfile, nexusCoreProfile, type FactionModuleProfile } from './faction-module-profile';
import { mixedE3NexusProfile, pairedE3NexusProfile, standaloneE3NexusProfile } from './nexus-module-profile';
export type EcazDukeContext = Partial<FactionModuleProfile> & {
@@ -57,7 +57,7 @@ export function quoteEcazDukeAcquisition(
);
requireDuke(
!(g.advanced && g.players.some((p) => p.faction === 'harkonnen')) ||
(!!g.expansions && (nexusCoreProfile(g as FactionModuleProfile) || standaloneE3NexusProfile(g as FactionModuleProfile) ||
(!!g.expansions && (independentDiscoveryProfile(g as FactionModuleProfile) || nexusCoreProfile(g as FactionModuleProfile) || standaloneE3NexusProfile(g as FactionModuleProfile) ||
pairedE3NexusProfile(g as FactionModuleProfile) || mixedE3NexusProfile(g as FactionModuleProfile))),
tableBlock,
'table',
+2 -2
View File
@@ -1,7 +1,7 @@
import type { Game, Player } from './engine';
import type { Leader } from './cards';
import { DUKE_VIDAL_ID, returnedDukeCapture, type DukeState } from './duke-vidal';
import { nexusCoreProfile, type FactionModuleProfile } from './faction-module-profile';
import { independentDiscoveryProfile, nexusCoreProfile, type FactionModuleProfile } from './faction-module-profile';
import { mixedE3NexusProfile, pairedE3NexusProfile, standaloneE3NexusProfile } from './nexus-module-profile';
export const DUKE_REVIVAL_NORMAL_COST = 5 as const;
@@ -85,7 +85,7 @@ export function resolveEcazDukeRevival(
);
requireDuke(
!(g.advanced && g.players.some((p) => p.faction === 'harkonnen')) ||
(!!g.expansions && (nexusCoreProfile(g as FactionModuleProfile) || standaloneE3NexusProfile(g as FactionModuleProfile) ||
(!!g.expansions && (independentDiscoveryProfile(g as FactionModuleProfile) || nexusCoreProfile(g as FactionModuleProfile) || standaloneE3NexusProfile(g as FactionModuleProfile) ||
pairedE3NexusProfile(g as FactionModuleProfile) || mixedE3NexusProfile(g as FactionModuleProfile))),
'table',
);
+20 -12
View File
@@ -1,5 +1,5 @@
import { classicNexusModulesProfile, mixedE1E2NexusProfile, mixedE3NexusProfile, pairedE3NexusProfile, pairedNexusModulesProfile, singleE1E2NexusProfile, standaloneE3NexusProfile } from './nexus-module-profile';
import { independentNexusProfile, nexusCoreProfile, nativeFactionTechProfile } from './faction-module-profile';
import { independentDiscoveryProfile, independentNexusProfile, nexusCoreProfile, nativeFactionTechProfile } 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';
@@ -8629,7 +8629,7 @@ function e3StrongholdFactionProfile(g: Game): boolean {
return native !== null && (native !== 'moritani' || !byFaction(g, 'harkonnen'));
}
function moritaniAssassinateModeSupported(g: Game) {
if (independentNexusProfile(g)) return g.advanced && !!byFaction(g, 'moritani');
if (independentNexusProfile(g) || independentDiscoveryProfile(g)) return g.advanced && !!byFaction(g, 'moritani');
if (mixedE3NexusProfile(g) || mixedE3NexusLeaderSkillsProfile(g) ||
standaloneE3NexusProfile(g) || pairedE3NexusProfile(g) || pairedE3NexusLeaderSkillsProfile(g))
return g.advanced && g.players.some(player => player.faction === 'moritani');
@@ -10646,7 +10646,7 @@ export function initializeFactionExpansionsGameForAudit(state: Game): Game {
return initialized;
}
function ecazOccupyCompositionSupported(g: Game): boolean {
if (independentNexusProfile(g) && byFaction(g, 'ecaz')) return true;
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)) &&
g.players.some(player => player.faction === 'ecaz')) return true;
@@ -10846,7 +10846,7 @@ export function initializeLeaderSkillsGameForAudit(state: Game): Game {
requireFreshFactionInventory(state);
}
if (state.discoveryEnabled) {
requireRule(classicDiscoveryLeaderSkillsProfile(state) || nativeDiscoveryLeaderSkillsProfile(state) || classicNexusLeaderSkillsProfile(state), 'Discovery Leader Skills require a fresh supported classic or native lobby with optional original Nexus, Tech or Advanced Strongholds and no unrelated overlays.');
requireRule(independentDiscoveryProfile(state) || classicDiscoveryLeaderSkillsProfile(state) || nativeDiscoveryLeaderSkillsProfile(state) || classicNexusLeaderSkillsProfile(state), 'Discovery Leader Skills require a fresh supported classic or native lobby with optional original Nexus, Tech or Advanced Strongholds and no unrelated overlays.');
requireFreshBaseRuntime(state);
requireFreshFactionInventory(state);
}
@@ -10858,11 +10858,11 @@ export function initializeLeaderSkillsGameForAudit(state: Game): Game {
const initialized = initializeSetupGameForAudit(g, homeworlds, nexusCoreProfile(g) || classicNexus || pairedNexusLeaderSkillsProfile(g) ||
singleE1E2NexusLeaderSkillsProfile(g) || mixedE1E2NexusLeaderSkillsProfile(g) ||
mixedE3 || standaloneE3NexusLeaderSkillsProfile(g) || pairedE3 || guildBetrayalNexusLeaderSkillsProfile(g), false,
classicDiscoveryLeaderSkillsProfile(g) || nativeDiscoveryLeaderSkillsProfile(g) || (g.discoveryEnabled === true && classicNexus), true,
independentDiscoveryProfile(g) || classicDiscoveryLeaderSkillsProfile(g) || nativeDiscoveryLeaderSkillsProfile(g) || (g.discoveryEnabled === true && classicNexus), true,
g.expansions.length === 1 && g.expansions[0] === 'choam', nativeExpansionLeaderSkillsProfile(g), independent);
if (advancedMoritaniLeaderSkillsProfile(initialized) || ((independentNexusProfile(initialized) || mixedE3 || pairedE3) && initialized.advanced &&
if (advancedMoritaniLeaderSkillsProfile(initialized) || ((independentDiscoveryProfile(initialized) || independentNexusProfile(initialized) || mixedE3 || pairedE3) && initialized.advanced &&
byFaction(initialized, 'moritani'))) initializeMoritaniAssassinateState(initialized);
if ((independentNexusProfile(initialized) || nativeEcazLeaderSkillsProfile(initialized) || mixedE3 || homeworlds || nativeDiscoveryLeaderSkillsProfile(initialized) || pairedE3) && byFaction(initialized, 'ecaz')) initialized.ecazOccupyPreview = true;
if ((independentDiscoveryProfile(initialized) || independentNexusProfile(initialized) || nativeEcazLeaderSkillsProfile(initialized) || mixedE3 || homeworlds || nativeDiscoveryLeaderSkillsProfile(initialized) || pairedE3) && byFaction(initialized, 'ecaz')) initialized.ecazOccupyPreview = true;
return initialized;
}
/** Independent cards keep every selected native family's physical counters. */
@@ -10882,7 +10882,7 @@ function independentCardModuleRoster(g: Game): boolean {
(g.expansions.length === 1 && g.expansions[0] === 'ecaz'));
}
function ecazHomeworldProfile(g: Game): boolean {
if (g.homeworlds && independentNexusProfile(g)) return true;
if (g.homeworlds && (independentNexusProfile(g) || independentDiscoveryProfile(g))) return true;
if (g.homeworlds && g.leaderSkills && !g.sandtrout && independentTreacheryLeaderSkillsProfile(g)) return true;
return !!g.homeworlds && g.ecazTreachery === true && independentCardModuleRoster(g) &&
g.players.length >= 2 && g.players.length <= 6 &&
@@ -10898,7 +10898,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) && independentNexusProfile(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;
if (!g.ecazTreachery || (!g.techTokens && !g.strongholdCards) || !independentCardModuleRoster(g) ||
@@ -10920,10 +10920,16 @@ export function initializeEcazTreacheryGameForAudit(state: Game): Game {
requireRule(!state.ecazTreachery, 'Ecaz Treachery Cards cannot redeal an existing inventory.');
const g = structuredClone(state);
g.ecazTreachery = true;
const discovery = independentDiscoveryProfile(g) && discoveryModuleProfile(g);
if (g.discoveryEnabled) {
requireRule(discovery, 'Independent Discovery cards require fresh original tokens and a supported selected-deck roster.');
requireFreshBaseRuntime(g);
requireFreshFactionInventory(g);
}
const homeworlds = !!g.homeworlds;
requireRule(!homeworlds || ecazHomeworldProfile(g),
'Independent World cards need a supported selected-deck roster with original phase modules and supported Nexus selections.');
const initialized = initializeSetupGameForAudit(g, homeworlds, independentNexusProfile(g), false, false, false, false, true, true);
const initialized = initializeSetupGameForAudit(g, homeworlds, independentNexusProfile(g), false, discovery, false, false, true, true);
if (byFaction(initialized, 'ecaz')) initialized.ecazOccupyPreview = true;
if (moritaniAssassinateModeSupported(initialized)) initializeMoritaniAssassinateState(initialized);
return initialized;
@@ -14030,6 +14036,7 @@ function ecazTreacheryIntegrity(g: Game) {
}
}
function recruitsModeSupported(g: Game) {
if (independentDiscoveryProfile(g) || independentNexusProfile(g)) return true;
const modules = independentCardModulesProfile(g);
return (
(!g.homeworlds || ecazHomeworldProfile(g)) &&
@@ -14054,6 +14061,7 @@ function combinedCardBattleBlock(g: Game): string | null {
return null;
}
function reinforcementsModeSupported(g: Game) {
if (independentDiscoveryProfile(g) || independentNexusProfile(g)) return true;
const pairedEcaz = g.expansions.length === 1 && g.expansions[0] === 'ecaz';
const nativeFamilies = !g.discoveries && !g.discoveryStash && !g.greatMaker &&
!g.semutaPreview && !g.kullPreview && !g.ecazOccupyPreview &&
@@ -16461,7 +16469,7 @@ function ambassadorEffectBlock(
return null;
}
function arrivalOverlapModeSupported(g: Game): boolean {
if (independentNexusProfile(g) && byFaction(g, 'ecaz') && byFaction(g, 'moritani')) return true;
if ((independentNexusProfile(g) || independentDiscoveryProfile(g)) && byFaction(g, 'ecaz') && byFaction(g, 'moritani')) return true;
if ((mixedE3NexusLeaderSkillsProfile(g) || mixedE3NexusProfile(g)) &&
byFaction(g, 'ecaz') && byFaction(g, 'moritani')) return true;
if (g.ecazTreachery && byFaction(g, 'ecaz') && byFaction(g, 'moritani') &&
@@ -18190,7 +18198,7 @@ function ecazAllianceQuote(g: Game, owner: string, entrant: string) {
}
function ecazAmbassadorLoanBlock(g: Game, owner: Player, entrant: Player) {
if (g.advanced && byFaction(g, 'harkonnen') &&
!(independentNexusProfile(g) || standaloneE3NexusProfile(g) || pairedE3NexusProfile(g) || mixedE3NexusProfile(g)))
!(independentDiscoveryProfile(g) || independentNexusProfile(g) || standaloneE3NexusProfile(g) || pairedE3NexusProfile(g) || mixedE3NexusProfile(g)))
return 'Advanced Duke loans with Harkonnen capture remain guarded in this profile.';
if (owner.ally !== entrant.id || entrant.ally !== owner.id)
return 'The new Ecaz alliance is no longer current.';
+21 -7
View File
@@ -42,19 +42,33 @@ export function selectedIndependentNexusProfile(game: NexusCoreConfiguration): b
return game.ecazTreachery === true && game.expansions.length > 0 && nexusCoreRosterProfile(game, true);
}
/** Common consumers of the classic and selected-independent fresh Nexus core. */
export function nexusCoreProfile(game: NexusCoreConfiguration): boolean {
return classicNexusCoreProfile(game) || selectedIndependentNexusProfile(game);
/** Independent Discovery composes selected decks and original optional modules.
* Nexus remains optional; live tokens and encounters are runtime state, not selections. */
export function independentDiscoveryProfile(game: NexusCoreConfiguration): boolean {
return game.ecazTreachery === true && game.discoveryEnabled === true &&
nexusCoreRosterProfile(game, true, true);
}
function nexusCoreRosterProfile(game: NexusCoreConfiguration, selectedDecks: boolean): boolean {
export function independentDiscoveryNexusProfile(game: NexusCoreConfiguration): boolean {
return !!game.nexusCards && independentDiscoveryProfile(game);
}
/** Common consumers of the classic and selected-independent fresh Nexus core. */
export function nexusCoreProfile(game: NexusCoreConfiguration): boolean {
return classicNexusCoreProfile(game) || selectedIndependentNexusProfile(game) ||
independentDiscoveryNexusProfile(game);
}
function nexusCoreRosterProfile(game: NexusCoreConfiguration, selectedDecks: boolean, discovery = false): boolean {
const players = game.players;
if (typeof game.advanced !== 'boolean' || !game.nexusCards ||
if (typeof game.advanced !== 'boolean' || (!discovery && !game.nexusCards) ||
!players || players.length < (game.techTokens ? 3 : 2) || players.length > 6 ||
(game.strongholdCards && !game.advanced) || game.sandtrout ||
game.discoveryEnabled || game.discoveries || game.discoveryStash || game.greatMaker ||
(game.strongholdCards && !game.advanced) ||
(!discovery && (game.discoveryEnabled || game.discoveries || game.discoveryStash || game.greatMaker)) ||
game.semutaPreview || game.advancedPreview || game.kullPreview || game.nexusKullPreview ||
game.richeseBetrayalPreview ||
(!game.nexusCards && (game.guildBetrayalPreview || game.nexusIxianReplacementPreview ||
game.nexusIxianBetrayalPreview || game.nexusHarkonnenBetrayalPreview)) ||
(game.moritaniAssassinatePreview && (!selectedDecks || !game.advanced ||
!players.some(player => player.faction === 'moritani'))) ||
(game.nexusIxianBetrayalPreview && (!selectedDecks || !players.some(player => player.faction === 'ixians')))) return false;
+3 -3
View File
@@ -1,5 +1,5 @@
import { FACTIONS } from './catalog';
import { independentNexusProfile, classicNexusCoreProfile, nexusCoreProfile, mixedE3NexusRosterProfile } from './faction-module-profile';
import { independentDiscoveryProfile, independentNexusProfile, classicNexusCoreProfile, nexusCoreProfile, mixedE3NexusRosterProfile } from './faction-module-profile';
import { classicDiscoveryLeaderSkillsProfile } from './discovery-module-profile';
/** Public configuration only; never gate a skill on a hidden card or random deal. */
@@ -463,7 +463,7 @@ function homeworldLeaderSkillModulesSupported(game: LeaderSkillProfile): boolean
/** Original Homeworld/Skills under the existing classic or native Nexus roster contracts. */
export function homeworldLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
if (!game.homeworlds) return false;
if (nexusCoreProfile(game)) return true;
if (nexusCoreProfile(game) || independentDiscoveryProfile(game)) return true;
if (game.expansions.length) return nativeExpansionLeaderSkillsProfile(game);
if (!homeworldLeaderSkillModulesSupported(game)) return false;
const players = game.players!;
@@ -479,7 +479,7 @@ export function homeworldLeaderSkillsProfile(game: LeaderSkillProfile): boolean
/** Independent cards with registered selected-family all14 setup and original module/role guards. */
export function independentTreacheryLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
if (independentNexusProfile(game)) return true;
if (independentNexusProfile(game) || independentDiscoveryProfile(game)) return true;
if (game.ecazTreachery !== true || game.expansions.length > 3 ||
typeof game.advanced !== 'boolean' || !game.players ||
game.players.length < (game.techTokens ? 3 : 2) || game.players.length > 6 ||
+11 -6
View File
@@ -4379,6 +4379,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Each non-advisor Testing Station occupant may decrease ordinary storm movement by one, keep it or increase it by one. The provisional order is the original storm order, once per owner, with cumulative adjustments on current distance. Choose after the ordinary card window; traversal/protection starts only after all choices. Weather Control is unaffected. Original natural source/base and existing sole-frame history remain authoritative; legacy movement without a source is not invented.',
'A current non-advisor Shrine occupant may play a physical Truthtrance card as Karama, or a physical Karama as Truthtrance, within the implemented timing and effect paths. The printed identity and actual card custody remain unchanged; a committed Truthtrance conversion survives departure. This does not create missing Karama powers or settle their pending rules.',
'All four AI profiles use the same offered inspection, reveal, stash-discard, Great Maker, free-entry, Ornithopter and Testing Station choices as the player controls. Private knowledge and unfinished decisions survive saved JSON continuations. These focused paths do not establish complete variant compliance or combined-module acceptance.',
'The independent three-card variant now composes original Discovery7/eight tokens with classic or registered selected0–3-family rosters, Basic/Advanced2–6, with or without Nexus12 and optional World/all14/Tech3+/Advanced Strongholds. Actual reveal/free entry, native Cunning into Cistern, Sandtrout suppression and purchased nested card battles retain original sources. Trained actual-E3/Harkonnen and unrelated previews remain guarded; broader interactions and full modes remain open.',
],
related: ['ecaz-modules', 'spice-blow', 'collection', 'movement', 'mentat', 'implementation-checklist'],
checklist: [
@@ -4404,8 +4405,8 @@ export const RULE_TOPICS: RuleTopic[] = [
},
{
area: 'Verification', status: 'Partial',
detail: 'Current Collection batch passes80/80 in6 related files, types/lint and build. Genuine Basic/Advanced cases cover shared equal/full/zero splits, acceptance timing, wrong actors and stale events; helper boundaries cover multiple deposits, exhausted recipients, self/zero-share exclusion and cumulative overflow. Actual390px Basic Red Chasm lot4 changes Ecaz7/Guild8 to8/11 with one transfer and Ready restored, no overflow or errors. Four legal policies complete that Basic allocation via actual actions. Earlier canonical-source and Jacurutu/Station evidence remains source-bound; combinations, assurance, strategy and deployed acceptance remain open.',
evidence: ['tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/homeworld-paired-nexus-runtime.test.ts', 'tests/homeworld-standalone-nexus-runtime.test.ts',
detail: 'Independent Discovery adds frozen15-file118/118, types/lint/build and six original-action/rendered-control programmes; broader modes, visual/deployed acceptance and final assurance remain open. Current Collection batch passes80/80 in6 related files, types/lint and build. Genuine Basic/Advanced cases cover shared equal/full/zero splits, acceptance timing, wrong actors and stale events; helper boundaries cover multiple deposits, exhausted recipients, self/zero-share exclusion and cumulative overflow. Actual390px Basic Red Chasm lot4 changes Ecaz7/Guild8 to8/11 with one transfer and Ready restored, no overflow or errors. Four legal policies complete that Basic allocation via actual actions. Earlier canonical-source and Jacurutu/Station evidence remains source-bound; combinations, assurance, strategy and deployed acceptance remain open.',
evidence: ['tests/independent-discovery-nexus-entry.test.ts', 'tests/independent-discovery-cards.test.ts', 'tests/independent-discovery-battles.test.ts', 'tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/homeworld-paired-nexus-runtime.test.ts', 'tests/homeworld-standalone-nexus-runtime.test.ts',
'tests/fixture-jacurutu-mixed-dial.ts', 'tests/jacurutu-mixed-dial-runtime.test.ts', 'tests/fixture-testing-station-contest.ts', 'tests/testing-station-contest-runtime.test.ts',
'tests/fixture-discovery-occupied-collection.ts', 'tests/discovery-occupied-collection-runtime.test.ts', 'tests/fixture-discovery-shared-orgiz.ts', 'tests/discovery-shared-orgiz-runtime.test.ts',
'tests/board-resolution-quote.test.ts', 'tests/ecaz-collection-quote.test.ts',
@@ -4758,6 +4759,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Guild Betrayal in fresh classic core or supported original native-Guild entries replaces one fully funded positive shipment fee, including an already-affordable own payment. Original escrow and physical delivery occur once; neutral acknowledgement reveals no fee/source, while private actual card authority controls Use. Core preserves Richese five-for-one and Guild Cunning source records through this same gate. The single Guild card spent for Cunning cannot also be held for rival Use. Zero/stopped shipments create no fee or Nexus award; wider World and income boundaries retain their own contracts.',
'Cunning spends its Nexus card and uses the native Mentat placement opportunity. A Karama response precedes placement; cancellation leaves the token where it was and the Nexus spent. Later Terror entry still uses the existing one-token stack selection and supported effect rules. If Extortion was revealed this turn, its bank award and payment choices follow that placement response; this does not implement Atomics or complete Moritani games.',
'Hidden reactive timing remains unresolved outside supported original protocols. Fresh normal previews do not certify complete Nexus games, every combination, comprehensive assurance or deployed acceptance.',
'Independent36/38/50 now retains original Discovery setup, tokens and revealed destinations with optional World/all14/phase modules. Real Cunning lands in Cistern; actual Sandtrout breaks an earned alliance and suppresses its next worm without spending the held Fremen card. Transient encounter state does not remove unrelated card capabilities. Existing effect guards, complete-mode and final assurance gates remain.',
],
related: ['ecaz-modules', 'spice-blow', 'alliance-funding', 'choam-worthless', 'choam-kull', 'nexus-choam-kull', 'nexus-richese-betrayal', 'nexus-guild-betrayal', 'nexus-ixian-replacement', 'nexus-ixian-betrayal'],
checklist: [
@@ -4765,7 +4767,7 @@ export const RULE_TOPICS: RuleTopic[] = [
{area:"Player controls",status:'Partial',detail:"Fremen table SSR retains original Cunning spend/ride, Betrayal and Great Maker controls; an actual pending Sandmaster quote also renders its shared checkbox. Native Betrayal/Atomics table SSR retains typed/concealed return, revelation, ordered arrival, Aftermath and desert controls; CSS stubs mean no visual/browser acceptance. Earlier selected E3 lead/plan, assassination/revival and Duke controls retain their dated proof. Four earlier original-action390px journeys prove native Junction5/full2, poor1/full2 release plus ordinary one-force replacement, still-held source sand2 disabled then five-force stronghold1, and independent purchased1. The response explains the spent-card consequence. No overflow, browser error or application exception; two tool-only stale-node errors were resolved by fresh evidence without replay. Earlier Junction/loan/refund surfaces remain scoped evidence.",evidence:['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/nexus-ecaz-duke.tsx', 'components/nexus-richese-betrayal.tsx', 'components/game-table.tsx', 'components/nexus-tleilaxu-revival.tsx', 'components/battle-preparation.tsx', 'components/nexus-fremen-revival.tsx','components/nexus-emperor-payment.tsx', 'components/nexus-emperor-secret-ally.tsx', "components/nexus-emperor-seller-purchase.tsx", "components/nexus-emperor-ambassador-purchase.tsx", "components/richese-special-karama.tsx", "components/junction-transport.tsx"]},
{area:"AI",status:'Partial',detail:"All four policies retain original Fremen Cunning and both Betrayal declarations with shared destination legality. All four policies retain minimal legal Ecaz Betrayal and original Aftermath/Sneak participation. All four policies execute original selected E3 Duke Cunning and unused expiry, and legal Occupy plans with real World sources. All four original policies also execute a legal still-held Richese full-rate one-counter alternative after native cancellation, with actual physical delivery and card cost. Shared human/AI quotes no longer promise a canceled discount. Native permission alternatives, recipient-specific lender prices and immune Junction source choice remain. Optional non-reoffer is not forced. No strategic first-candidate pin, tuning or calibration.",evidence:['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/nexus-ecaz-duke-options.ts', 'game/nexus-richese-options.ts', 'game/bot-nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts', 'game/nexus-fremen-revival-options.ts','game/nexus-emperor-payment.ts', 'game/nexus-emperor-secret-ally-options.ts']},
{area:"Documentation",status:'Partial',detail:"Selected E3 scope, printed two-seat no-alliance draw limit and remaining individual-effect envelopes are explicit in the canonical Nexus contract. Richese printed one-counter pricing and native Guild eligibility stay distinct from absent-faction cancellation, physical force count and independent purchased price. Unfunded spent-card/no-refund release is selected existing no-debt composition, not a new publisher worked example. Immune Junction and earlier permission/refund/loan interpretations remain explicit. Full rules and public gates stay open.",evidence:['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_FREMEN_RULES.md', 'docs/NEXUS_ECAZ_RULES.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_RICHESE_RULES.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md', 'docs/GUILD_RATE_KARAMA_RULES.md', 'docs/RICHESE_ALLIED_SHIPMENT_AUDIT.md', 'docs/GUILD_HOMEWORLD_TRANSPORT_RULES.md', 'docs/NEXUS_GUILD_RULES.md', "docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md", "docs/RULE_DECISIONS.md", "docs/RICHESE_AMBASSADOR_RULES.md", "docs/RICHESE_AMBASSADOR_IMPLEMENTATION.md", "docs/RICHESE_ACQUISITION_RULES.md", "docs/DUKE_VIDAL_RULES.md", "docs/ECAZ_DUKE_ACQUISITION.md", "docs/ECAZ_REVIVAL_RULES.md", "docs/JUNCTION_TRANSPORT_RULES.md", "docs/JUNCTION_TRANSPORT_RUNTIME.md"]},
{area:"Verification",status:'Partial',detail:"Native Fremen modules pass frozen14-file86/86, types/lint/build and nine original-action/rendered-control programmes. HMS has genuine runtime proof; Discovery destination arrival remains quote-only evidence. Earlier native Betrayal/Atomics composition passes frozen16-file115/115, types/lint/build and eight finite original-action/table-SSR programmes, including original Cunning desert Aftermath; visual/browser and deployed acceptance remain open. Earlier selected E3 entry, Occupy, Assassinate/Recruits, Duke and arrival proof remains dated in the canonical contract. Earlier selected E1/E2 native card/cancellation proof remains dated in the canonical Nexus contract. Classic core retains original World/Tech/Stronghold modules with base33/selected36 and optional14. Independent World cases use native setup, population-sensitive Recruits, typed foreign Harass and actual own-Kaitain Reinforcements/Cunning. The local fee reduces only its paid temporary roles; original count5 and real stars remain. Earlier phase/card proofs are dated in the canonical contract. Native-family/Discovery/Semuta envelopes, full-mode and comprehensive assurance remain open.",evidence:['tests/fremen-cunning-modules.test.ts','tests/fixture-fremen-cunning-modules.ts','tests/fremen-betrayal-modules.test.ts','tests/fixture-fremen-betrayal-modules.ts','tests/nexus-fremen-cunning-modules-quotes.test.ts','tests/ecaz-betrayal-modules.test.ts','tests/fixture-ecaz-betrayal-modules.ts','tests/richese-no-field-hidden-return.test.ts','tests/nexus-ecaz-betrayal-quotes.test.ts','tests/moritani-atomics-modules.test.ts','tests/nexus-moritani-atomics-runtime.test.ts','tests/nexus-world-independent-cards.test.ts','tests/nexus-world-independent-battles.test.ts','tests/reinforcements-temporary-role.test.ts','tests/nexus-core-tech-runtime.test.ts','tests/fixture-nexus-core-tech.ts','tests/nexus-core-strongholds-runtime.test.ts','tests/fixture-nexus-core-strongholds.ts','tests/classic-nexus-core.test.ts','tests/fixture-classic-nexus-core.ts','game/faction-module-profile.ts','tests/classic-independent-nexus-utility.test.ts', 'tests/classic-independent-nexus-battle.test.ts', 'tests/fixture-classic-independent-nexus.ts', 'tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', 'tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/native-guild-rate-karama.test.ts', 'tests/native-guild-rate-modules.test.ts', 'tests/allied-marker-guild-rate.test.ts', 'tests/purchased-homeworld-rate.test.ts', 'tests/native-guild-transport-permission.test.ts', 'tests/native-guild-homeworld-rate.test.ts', 'tests/native-guild-rate-betrayal.test.ts', 'tests/nexus-guild-cunning-engine.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts', 'tests/classic-nexus-skills-payments-runtime.test.ts', 'tests/classic-nexus-skills-battles-runtime.test.ts', 'tests/nexus-skills-modules-payments-runtime.test.ts', 'tests/nexus-skills-modules-battles-runtime.test.ts', 'tests/paired-ix-nexus-skills-modules-runtime.test.ts', 'tests/paired-choam-nexus-skills-modules-runtime.test.ts', 'tests/ecaz-nexus-skills-modules-runtime.test.ts', 'tests/moritani-nexus-skiLine truncated
{area:"Verification",status:'Partial',detail:"Independent Discovery adds frozen15-file118/118, types/lint/build and six original-action/rendered-control programmes; broader modes, visual/deployed acceptance and final assurance remain open. Native Fremen modules pass frozen14-file86/86, types/lint/build and nine original-action/rendered-control programmes. HMS has genuine runtime proof; Discovery destination arrival remains quote-only evidence. Earlier native Betrayal/Atomics composition passes frozen16-file115/115, types/lint/build and eight finite original-action/table-SSR programmes, including original Cunning desert Aftermath; visual/browser and deployed acceptance remain open. Earlier selected E3 entry, Occupy, Assassinate/Recruits, Duke and arrival proof remains dated in the canonical contract. Earlier selected E1/E2 native card/cancellation proof remains dated in the canonical Nexus contract. Classic core retains original World/Tech/Stronghold modules with base33/selected36 and optional14. Independent World cases use native setup, population-sensitive Recruits, typed foreign Harass and actual own-Kaitain Reinforcements/Cunning. The local fee reduces only its paid temporary roles; original count5 and real stars remain. Earlier phase/card proofs are dated in the canonical contract. Native-family/Discovery/Semuta envelopes, full-mode and comprehensive assurance remain open.",evidence:['tests/independent-discovery-nexus-entry.test.ts', 'tests/independent-discovery-cards.test.ts', 'tests/independent-discovery-battles.test.ts', 'tests/fremen-cunning-modules.test.ts','tests/fixture-fremen-cunning-modules.ts','tests/fremen-betrayal-modules.test.ts','tests/fixture-fremen-betrayal-modules.ts','tests/nexus-fremen-cunning-modules-quotes.test.ts','tests/ecaz-betrayal-modules.test.ts','tests/fixture-ecaz-betrayal-modules.ts','tests/richese-no-field-hidden-return.test.ts','tests/nexus-ecaz-betrayal-quotes.test.ts','tests/moritani-atomics-modules.test.ts','tests/nexus-moritani-atomics-runtime.test.ts','tests/nexus-world-independent-cards.test.ts','tests/nexus-world-independent-battles.test.ts','tests/reinforcements-temporary-role.test.ts','tests/nexus-core-tech-runtime.test.ts','tests/fixture-nexus-core-tech.ts','tests/nexus-core-strongholds-runtime.test.ts','tests/fixture-nexus-core-strongholds.ts','tests/classic-nexus-core.test.ts','tests/fixture-classic-nexus-core.ts','game/faction-module-profile.ts','tests/classic-independent-nexus-utility.test.ts', 'tests/classic-independent-nexus-battle.test.ts', 'tests/fixture-classic-independent-nexus.ts', 'tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', 'tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/native-guild-rate-karama.test.ts', 'tests/native-guild-rate-modules.test.ts', 'tests/allied-marker-guild-rate.test.ts', 'tests/purchased-homeworld-rate.test.ts', 'tests/native-guild-transport-permission.test.ts', 'tests/native-guild-homeworld-rate.test.ts', 'tests/native-guild-rate-betrayal.test.ts', 'tests/nexus-guild-cunning-engine.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts', 'tests/classic-nexus-skills-payments-runtime.test.ts', 'tests/classic-neLine truncated
],
},
{
@@ -5012,6 +5014,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Classic or selected Ix/CHOAM native/base rosters, including Homeworlds, may retain original unused Tech Tokens3–6 and/or Stronghold Cards2–6 Advanced. Setup, first-Storm Tech and actual end-Mentat custody remain original. Recruits changes current native free rates, not the once-per-phase Axlotl trigger/delayed payout. Harass returns are not free Revival.',
'Selected Ix/CHOAM native/base Basic/Advanced Worlds use original38/50 independent-card setup with optional original phase modules. Current native bonuses, typed deposits and accepted paid/free classifications stay. Ix keeps its multiple-Cyborg allowance; Tleilaxu and CHOAM remain unlimited rather than being reduced to seven. Other card/overlay/public-start gates remain.',
'Supported original all14/card rosters retain Tech3+ and Advanced Strongholds2+, including classic/E1/E2 Worlds. Current native free allowance doubles after recorded use; separate World groups re-observe native population. Typed elite caps, unlimited native allowances, paid Cyborg surcharges and any Tleilaxu recipient stay. Axlotl accrues once and waits for phase end; normal Banker uses the accepted final Bank invoice and waits for Mentat, not a net wallet delta after a separate native payout. No ledger, refund, owner or premature spendable income is invented.',
'Independent Discovery, with or without Nexus, retains original optional World/all14/phase modules. Purchased Recruits may double the native free rate against actual earlier nested-battle losses; accepted physical fee and dial casualties supply the Tanks. Existing late-play/current-rate policies stay provisional. Broader interactions and complete modes remain open.',
],
related: ['revival', 'homeworlds', 'ecaz-modules', 'expansion-faction-games', 'implementation-checklist'],
checklist: [
@@ -5019,7 +5022,7 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area: 'Player controls', status: 'Implemented', detail: 'The owned physical card has full inspection, a Revival action and explicit unavailable reasons. All seats see the effective public rates after play.', evidence: ['components/recruits.tsx', 'components/game-table.tsx', 'game/card-presentation.ts'] },
{ area: 'AI', status: 'Partial', detail: 'All four profiles use the same legal owned offer for useful own or allied revival, then existing authoritative revival choices. Complete card strategy and calibration remain unfinished.', evidence: ['game/bots.ts'] },
{ area: 'Documentation', status: 'Implemented', detail: 'The guide records examples, limits, independent-cap inference and the visibly provisional late-paid policy; source alternatives and late-rate questions remain explicit.', evidence: ['docs/RECRUITS_RULES.md', 'docs/RECRUITS_RUNTIME.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Earlier classic World/phase evidence stays dated in linked contracts. Selected native Worlds add real typed deposits/prices, current population crossings, prior-paid reopening, unlimited Tleilaxu/CHOAM returns and ordinary Richese/Imperial elite caps. One mobile11-force revival/9 actions and40 useful four-policy programmes/300 actions reach legal shipment. Policy card holders are explicit conserved staging. Complete modes/calibration remain open.', evidence: ['tests/native-world-recruits.test.ts', 'tests/fixture-native-world-recruits.ts', 'tests/world-phase-recruits.test.ts', 'tests/fixture-world-phase-recruits.ts', 'tests/recruits-modules.test.ts', 'tests/fixture-recruits-modules.ts', 'tests/fixture-independent-card-modules.ts', 'tests/recruits-homeworld.test.ts', 'tests/fixture-recruits-homeworld.ts', 'tests/recruits.test.ts', 'tests/recruits-engine.test.ts', 'tests/recruits-controls.test.tsx', 'tests/recruits-recovery.test.ts', 'tests/prototype-room.test.ts'] },
{ area: 'Verification', status: 'Partial', detail: 'Independent Discovery adds frozen15-file118/118, types/lint/build and six original-action/rendered-control programmes; broader modes, visual/deployed acceptance and final assurance remain open. Earlier classic World/phase evidence stays dated in linked contracts. Selected native Worlds add real typed deposits/prices, current population crossings, prior-paid reopening, unlimited Tleilaxu/CHOAM returns and ordinary Richese/Imperial elite caps. One mobile11-force revival/9 actions and40 useful four-policy programmes/300 actions reach legal shipment. Policy card holders are explicit conserved staging. Complete modes/calibration remain open.', evidence: ['tests/independent-discovery-nexus-entry.test.ts', 'tests/independent-discovery-cards.test.ts', 'tests/independent-discovery-battles.test.ts', 'tests/native-world-recruits.test.ts', 'tests/fixture-native-world-recruits.ts', 'tests/world-phase-recruits.test.ts', 'tests/fixture-world-phase-recruits.ts', 'tests/recruits-modules.test.ts', 'tests/fixture-recruits-modules.ts', 'tests/fixture-independent-card-modules.ts', 'tests/recruits-homeworld.test.ts', 'tests/fixture-recruits-homeworld.ts', 'tests/recruits.test.ts', 'tests/recruits-engine.test.ts', 'tests/recruits-controls.test.tsx', 'tests/recruits-recovery.test.ts', 'tests/prototype-room.test.ts'] },
],
},
{
@@ -5039,6 +5042,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'One canonical physical-name registry now includes the independent cards for current facts and named battle conditions. Naming Reinforcements in a weapon slot does not make it a weapon category. Native questions use actual combat-location labels. Source-aware legal completion, private full-plan/read-only review and the fill button retain exact native sources.',
'Selected Ix/CHOAM Worlds retain original38/50 setup and native stock, optionally with original Tech3+ and Advanced Strongholds2+. Cyborg substitution and Face Dance use their actual physical pools, never a duplicate fee origin. Native Richese must retain three World reserves AFTER its own projected No-Field reveal before R seals; rejection moves no counter or marker. Original phase rewards and earned held-card support stay; admitted Stone compositions retain their separate guards.',
'Supported all14/card World/phase profiles keep the same physical fee and score. An own-World fee cannot fund the same physical dial/casualty; pre-fee native traits/face remain original. Suk rescues actual committed losses, never fee counters, keeping native saves at that home or using original typed external destinations. Earned Arrakeen never subsidizes a World or reserve fee. A Richese marker must leave three after materialization; future Harass cannot fund a refused seal. Native E3 card Worlds and unrelated guards remain.',
'Independent Discovery, optionally Nexus/World/all14/phase, retains the printed three-reserve fee in actual revealed nested battles. Original Jacurutu entry and paid enemy shipment precede purchased R/Harass settlement; Kaitain normals pay before undialed Sardaukar return to Salusa. Future returns cannot fund the fee. Existing provisional ordering and full-mode gates remain.',
],
related: ['battle-cards', 'battle', 'homeworlds', 'card-recruits', 'card-harass-withdraw', 'card-truthtrance', 'implementation-checklist'],
checklist: [
@@ -5046,7 +5050,7 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area: 'Player controls', status: 'Partial', detail: 'Owner-only cost/source choices, own-World physical bounds, wrapped exact allocation, source-aware legal-plan fill and earned private reviews connect; category answers remain None. Native named-question preview/answer/history uses canonical combat labels.', evidence: ['components/battle-preparation.tsx', 'components/reinforcements-sources.tsx', 'components/private-battle-plan.tsx', 'components/battle-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Four profiles consider legal physical slot candidates and the normal score bonus; strategy and calibration remain unfinished.', evidence: ['game/bots.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'The printed effect and unresolved Traitor, explosion, type-selection and accounting interpretations are distinguished from this temporary prototype.', evidence: ['docs/ECAZ_TREACHERY_RULES.md', 'docs/REINFORCEMENTS_RUNTIME.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Earlier classic/family/phase evidence stays dated. Native Worlds add1N2E fees before normal returns, own-Ix fee/dial disjointness/H ban, one board-Cyborg exchange versus fee casualties, native Face Dance/Auditor and marker funding/caps. Four mobile battle/marker journeys33 actions;52 four-policy programmes232 continuations5280 R-or-H proposals preserve normal outcomes. Before/after raw marker3/reserves5 proves unchanged first-seal rejection. No complete-mode/calibration/deployed claim.', evidence: ['tests/native-world-battle.test.ts', 'tests/fixture-native-world-battle.ts', 'tests/native-world-marker.test.ts', 'tests/fixture-native-world-marker.ts', 'tests/world-phase-battle.test.ts', 'tests/fixture-world-phase-battle.ts', 'tests/battle-card-modules.test.ts', 'tests/fixture-battle-card-modules.ts', 'tests/fixture-independent-card-modules.ts', 'tests/native-family-reinforcements.test.ts', 'tests/fixture-native-family-reinforcements.ts', 'tests/richese-reinforcements.test.ts', 'tests/fixture-richese-reinforcements.ts', 'tests/reinforcements-homeworld.test.ts', 'tests/fixture-reinforcements-homeworld.ts', 'tests/reinforcements-homeworld-arrakis.test.ts', 'tests/fixture-reinforcements-homeworld-arrakis.ts', 'tests/no-field-reinforcements.test.ts', 'tests/fixture-no-field-reinforcements.ts', 'tests/stone-reinforcements.test.ts', 'tests/harass-reinforcements.test.ts', 'tests/reinforcements.test.ts', 'tests/reinforcements-engine.test.ts', 'tests/ecaz-occupy-cards-runtime.test.ts', 'docs/ECAZ_OCCUPY_RULES.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Independent Discovery adds frozen15-file118/118, types/lint/build and six original-action/rendered-control programmes; broader modes, visual/deployed acceptance and final assurance remain open. Earlier classic/family/phase evidence stays dated. Native Worlds add1N2E fees before normal returns, own-Ix fee/dial disjointness/H ban, one board-Cyborg exchange versus fee casualties, native Face Dance/Auditor and marker funding/caps. Four mobile battle/marker journeys33 actions;52 four-policy programmes232 continuations5280 R-or-H proposals preserve normal outcomes. Before/after raw marker3/reserves5 proves unchanged first-seal rejection. No complete-mode/calibration/deployed claim.', evidence: ['tests/independent-discovery-nexus-entry.test.ts', 'tests/independent-discovery-cards.test.ts', 'tests/independent-discovery-battles.test.ts', 'tests/native-world-battle.test.ts', 'tests/fixture-native-world-battle.ts', 'tests/native-world-marker.test.ts', 'tests/fixture-native-world-marker.ts', 'tests/world-phase-battle.test.ts', 'tests/fixture-world-phase-battle.ts', 'tests/battle-card-modules.test.ts', 'tests/fixture-battle-card-modules.ts', 'tests/fixture-independent-card-modules.ts', 'tests/native-family-reinforcements.test.ts', 'tests/fixture-native-family-reinforcements.ts', 'tests/richese-reinforcements.test.ts', 'tests/fixture-richese-reinforcements.ts', 'tests/reinforcements-homeworld.test.ts', 'tests/fixture-reinforcements-homeworld.ts', 'tests/reinforcements-homeworld-arrakis.test.ts', 'tests/fixture-reinforcements-homeworld-arrakis.ts', 'tests/no-field-reinforcements.test.ts', 'tests/fixture-no-field-reinforcements.ts', 'tests/stone-reinforcements.test.ts', 'tests/harass-reinforcements.test.ts', 'tests/reinforcements.test.ts', 'tests/reinforcements-engine.test.ts', 'tests/ecaz-occupy-cards-runtime.test.ts', 'docs/ECAZ_OCCUPY_RULES.md'] },
],
},
{
@@ -5068,6 +5072,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Classic and selected Ix/CHOAM Basic/Advanced Worlds may choose the independent cards before original setup. Harass works on Arrakis or a foreign World, never an own World. Return actual undialed board/visitor types through native custody: normal/special to the original primary World except Advanced Emperor normal to Kaitain and Sardaukar to Salusa. Original commitment stays across thresholds; native-only World Traitors/Face Dancers, late defense/explosion and disposal stay.',
'Classic or selected Ix/CHOAM native/base rosters, including Homeworlds, compose original Tech3+ and/or Advanced Strongholds2+. An earned held Stronghold affects only its original eligible plan/actual support; +2 R adds no supported counters. Harass deposits are not off-planet shipment/free Revival. Original winner card cleanup/Tech reward and native queues retain their order.',
'Supported all14/card World/phase profiles keep actual typed undialed complements and selected-trainer effects. Ix returns Suboids/Cyborgs, then rescues only committed losses before original aftercare/Face Dance. Visitor returns use original native custody; Advanced Emperor normals go to Kaitain and elites to Salusa. Every own World still forbids native Harass. Earned held support is local to its eligible holder-plan, not a World subsidy or fee funding. Capped Richese may use legal Harass when R is unfunded; no token or reserve pool resets.',
'Independent Discovery, optionally Nexus/World/all14/phase, retains actual undialed typed returns in revealed nested battles. Paid cards, real reveal/free entry and ordinary enemy shipment precede fixed original settlement. Withdrawal creates no Tanks, free revival or shipment reward; only actual later losses fund Recruits. Existing own-World ban and provisional ordering remain.',
],
related: ['battle-cards', 'battle', 'card-recruits', 'homeworlds', 'ecaz-modules', 'implementation-checklist'],
checklist: [
@@ -5075,7 +5080,7 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area: 'Player controls', status: 'Partial', detail: 'Either slot and native typed allocation remain. World names, Basic versus Advanced reserve destinations and fixed-commitment timing are explicit. Invaders no longer receive forbidden Traitor controls. Four actual390px journeys complete Basic foreign loss, Advanced foreign victory, Salusa threshold return and own-Salusa ban/plain alternative into Collection.', evidence: ['components/battle-preparation.tsx', 'components/harass-withdraw.tsx', 'components/revealed-battle.tsx', 'components/game-table.tsx', 'components/harass-withdraw-choice.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All four profiles use legal shared precommit quotes and exact after-reveal allocations without an invader vote. Sixteen native World programs accept36 continuation actions and validate original precommit proposals through normal/explosion/own-World alternatives. Retreat strategy and combined strength remain deferred.', evidence: ['game/bots.ts', 'tests/ecaz-battle-bots.test.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Printed own-World restriction and explicit native destination/fixed-commitment composition are separated from original materialization/Stone policies and remaining faction/module/final gates.', evidence: ['docs/HARASS_WITHDRAW_RUNTIME.md', 'docs/ECAZ_TREACHERY_RULES.md', 'docs/STONE_BURNER_ENGINE_AUDIT.md', 'docs/RICHESE_NO_FIELD_RULES.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Earlier World/phase/No-Field/Stone proofs stay dated. Selected native Worlds add exact primary typed deposits, normal-first R payment before7 Suboids return, one eligible board-Cyborg exchange and native-only World Face Dance/Auditor. Four mobile battle/marker journeys33 actions;52 four-policy programmes232 continuations5280 R-or-H proposals reach original queues. Unfunded R stays unspent under legal H fallback. No complete-mode/deployed/calibration claim.', evidence: ['tests/native-world-battle.test.ts', 'tests/fixture-native-world-battle.ts', 'tests/native-world-marker.test.ts', 'tests/fixture-native-world-marker.ts', 'tests/world-phase-battle.test.ts', 'tests/fixture-world-phase-battle.ts', 'tests/battle-card-modules.test.ts', 'tests/fixture-battle-card-modules.ts', 'tests/fixture-independent-card-modules.ts', 'tests/harass-homeworld.test.ts', 'tests/fixture-harass-homeworld.ts', 'tests/harass-homeworld-arrakis.test.ts', 'tests/fixture-harass-homeworld-arrakis.ts', 'tests/harass-no-field.test.ts', 'tests/fixture-harass-no-field.ts', 'tests/no-field-reinforcements.test.ts', 'tests/stone-harass-quote.test.ts', 'tests/stone-harass-runtime.test.ts', 'tests/fixture-stone-harass.ts', 'tests/ecaz-occupy-harass-quote.test.ts', 'tests/harass-allocation-engine.test.ts', 'tests/harass-allocation-recovery.test.ts', 'tests/harass-withdraw-choice-controls.test.tsx', 'tests/harass-withdraw.test.ts', 'tests/ecaz-battle-controls.test.tsx', 'tests/ecaz-battle-bots.test.ts', 'tests/ecaz-battle-recovery.test.ts'] },
{ area: 'Verification', status: 'Partial', detail: 'Independent Discovery adds frozen15-file118/118, types/lint/build and six original-action/rendered-control programmes; broader modes, visual/deployed acceptance and final assurance remain open. Earlier World/phase/No-Field/Stone proofs stay dated. Selected native Worlds add exact primary typed deposits, normal-first R payment before7 Suboids return, one eligible board-Cyborg exchange and native-only World Face Dance/Auditor. Four mobile battle/marker journeys33 actions;52 four-policy programmes232 continuations5280 R-or-H proposals reach original queues. Unfunded R stays unspent under legal H fallback. No complete-mode/deployed/calibration claim.', evidence: ['tests/independent-discovery-nexus-entry.test.ts', 'tests/independent-discovery-cards.test.ts', 'tests/independent-discovery-battles.test.ts', 'tests/native-world-battle.test.ts', 'tests/fixture-native-world-battle.ts', 'tests/native-world-marker.test.ts', 'tests/fixture-native-world-marker.ts', 'tests/world-phase-battle.test.ts', 'tests/fixture-world-phase-battle.ts', 'tests/battle-card-modules.test.ts', 'tests/fixture-battle-card-modules.ts', 'tests/fixture-independent-card-modules.ts', 'tests/harass-homeworld.test.ts', 'tests/fixture-harass-homeworld.ts', 'tests/harass-homeworld-arrakis.test.ts', 'tests/fixture-harass-homeworld-arrakis.ts', 'tests/harass-no-field.test.ts', 'tests/fixture-harass-no-field.ts', 'tests/no-field-reinforcements.test.ts', 'tests/stone-harass-quote.test.ts', 'tests/stone-harass-runtime.test.ts', 'tests/fixture-stone-harass.ts', 'tests/ecaz-occupy-harass-quote.test.ts', 'tests/harass-allocation-engine.test.ts', 'tests/harass-allocation-recovery.test.ts', 'tests/harass-withdraw-choice-controls.test.tsx', 'tests/harass-withdraw.test.ts', 'tests/ecaz-battle-controls.test.tsx', 'tests/ecaz-battle-bots.test.ts', 'tests/ecaz-battle-recovery.test.ts'] },
],
},
{
+9
View File
@@ -83,6 +83,15 @@ function command(g: Game): Action {
};
}
const acquire = (g: Game) => applyAction(g, 'ec', command(g));
void test('independent Discovery without Nexus admits the untrained Advanced Ecaz/Harkonnen acquisition quote', () => {
const g = fixture('harkonnen', true);
g.ecazTreachery = true;
g.discoveryEnabled = true;
assert.equal(g.nexusCards, undefined);
assert.equal(quoteEcazDukeAcquisition(g, 'ec').controller, 'ec');
const excluded = { ...g, leaderSkills: {} };
assert.throws(() => quoteEcazDukeAcquisition(excluded as Game, 'ec'), EcazDukeAcquisitionError);
});
function reject(g: Game, id: string, action: Action) {
const before = structuredClone(g);
assert.throws(() => applyAction(g, id, action), RuleError);
+10
View File
@@ -40,6 +40,16 @@ function fixture(advanced = false) {
g.dukeVidal!.leader.deaths = 1;
return g;
}
void test('independent Discovery without Nexus admits the untrained Advanced Ecaz/Harkonnen revival quote', () => {
const g = fixture(true);
g.players[1] = newPlayer('h', 'Harkonnen', 'harkonnen');
g.ecazTreachery = true;
g.discoveryEnabled = true;
assert.equal(g.nexusCards, undefined);
assert.equal(quoteEcazDukeRevival(g, 'ec', false).cost, 5);
const excluded = { ...g, leaderSkills: {} };
assert.throws(() => quoteEcazDukeRevival(excluded as Game, 'ec', false), EcazDukeRevivalError);
});
void test('canonical dead shared Duke resolves without manufacturing or reviving it and always has printed normal price five', () => {
for (const advanced of [false, true]) {
const g = fixture(advanced);
@@ -0,0 +1,224 @@
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { JACURUTU_SIETCH, DISCOVERY_CARD_PLACEMENTS } from '../game/discoveries';
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
import { nativeShipmentSources } from '../game/homeworld-options';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
import { RECRUITS_CARD_ID, recruitsPlayAction } from '../game/recruits';
import { createSelectedIndependentEntry, type SelectedIndependentEntryOptions } from './fixture-selected-independent-nexus-entry';
import { e3IndependentDukeClean, nextE3IndependentDukeStep } from './fixture-e3-independent-nexus-duke';
import { withClassicDiscoveryNexusLottery, withClassicDiscoveryNexusToken } from './fixture-discovery-classic-nexus';
export type IndependentDiscoveryBattleStep = { actor: string; action: Action };
export type IndependentDiscoveryBattleOptions = Omit<SelectedIndependentEntryOptions, 'factions' | 'discoveries'>;
export const independentDiscoveryBattleClean = e3IndependentDukeClean;
export const independentDiscoveryBattlePlayer = (game: Game, actor: string) => {
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
};
export function actIndependentDiscoveryBattle(game: Game, actor: string, action: Action,
actions: IndependentDiscoveryBattleStep[] = []): Game {
actions.push({ actor, action: structuredClone(action) });
return withClassicDiscoveryNexusLottery(() => applyAction(game, actor, action));
}
export function advanceIndependentDiscoveryBattle(game: Game, until: (state: Game) => boolean,
actions: IndependentDiscoveryBattleStep[] = []): Game {
for (let count = 0; count < 2400; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original nested battle programme ended before its next owned boundary');
assert.notEqual(game.decision?.kind, 'harassWithdraw', 'Supply the actual typed Harass allocation explicitly');
const step = nextE3IndependentDukeStep(game);
assert.ok(step, `Complete the real ${game.decision?.kind ?? 'Battle Plan'} at ${game.turn}/${game.phase}`);
game = actIndependentDiscoveryBattle(game, step.actor, step.action, actions);
}
throw Error('The original independent Discovery battle did not reach its finite boundary');
}
/** Only original unopened Spice Cards are ordered. No discarded encounter is
* retagged as a new blow, and no already-placed Discovery token is exchanged. */
function orderSpice(game: Game, discovery: boolean) {
const front: Game['spiceDeck'] = [];
for (let pile = 0; pile < (game.advanced ? 2 : 1); pile++) {
const at = game.spiceDeck.findIndex(card => 'territory' in card &&
(discovery && pile === 0 ? card.discovery === 'discovery-hagga-basin' : !card.discovery));
assert.ok(at >= 0); front.push(game.spiceDeck.splice(at, 1)[0]);
}
game.spiceDeck.unshift(...front);
}
/** Three genuinely paid original lots, all selected while still unopened.
* The ordinary native auction handles payment, card ownership and hand limit. */
function purchaseOriginalCards(game: Game, actions: IndependentDiscoveryBattleStep[]) {
assert.equal(game.phase, 2);
const desired = [REINFORCEMENTS_CARD, HARASS_WITHDRAW_CARD, RECRUITS_CARD_ID];
const front = desired.map(id => {
const at = game.deck.findIndex(card => card.id === id); assert.ok(at >= 0, `Original ${id} must still be undealt`);
return game.deck.splice(at, 1)[0];
});
game.deck.unshift(...front);
const before = structuredClone(game), bids: { before: Game; step: IndependentDiscoveryBattleStep; after: Game }[] = [];
for (let count = 0; count < 420; count++) {
if (desired.every(id => independentDiscoveryBattlePlayer(game, 'emperor').hand.some(card => card.id === id))) {
assert.equal(bids.length, 3); return { game, before, bids };
}
if (independentDiscoveryBattleClean(game) && game.auction?.active === 'emperor' &&
desired.includes(game.auction.cards[game.auction.index].id as typeof desired[number]) && game.auction.bid === 0) {
const prior = structuredClone(game), step: IndependentDiscoveryBattleStep = {
actor: 'emperor', action: { type: 'bid', amount: 1, allyPayment: 0 },
};
game = actIndependentDiscoveryBattle(game, step.actor, step.action, actions);
bids.push({ before: prior, step, after: structuredClone(game) });
} else {
const step = nextE3IndependentDukeStep(game); assert.ok(step);
game = actIndependentDiscoveryBattle(game, step.actor, step.action, actions);
}
}
throw Error('The actual first auction did not deliver its three paid original card lots');
}
/** Original placement, paid finder, actual inspection/reveal, next-turn free
* nested entry and paid enemy shipment. No dealt card, counter, wallet, phase,
* native reserve pool, leader identity or earned receipt is staged. */
export function createIndependentDiscoveryBattleFixture(options: Partial<IndependentDiscoveryBattleOptions> = {}) {
const source = withClassicDiscoveryNexusLottery(() => createSelectedIndependentEntry({
...options, advanced: options.advanced ?? true, expansions: options.expansions ?? ['ix', 'choam', 'ecaz'],
factions: ['emperor', 'guild', 'atreides'], discoveries: true,
}));
let game = structuredClone(source.game);
const afterSetup = structuredClone(game), actions = [...source.actions];
const act = (actor: string, action: Action) => { game = actIndependentDiscoveryBattle(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceIndependentDiscoveryBattle(game, until, actions); };
orderSpice(game, true);
for (let count = 0; !game.discoveries!.tokens.some(token => token.face === JACURUTU_SIETCH && token.status === 'placed') && count < 100; count++) {
const step = nextE3IndependentDukeStep(game); assert.ok(step);
actions.push(structuredClone(step));
// The token's own scalar producer surrounds applyAction directly; the
// generic shuffle wrapper must not replace this original supply lottery.
game = withClassicDiscoveryNexusToken(game, JACURUTU_SIETCH, () => applyAction(game, step.actor, step.action));
}
const token = game.discoveries!.tokens.find(token => token.face === JACURUTU_SIETCH && token.status === 'placed')?.id;
assert.ok(token);
const placed = structuredClone(game), parent = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'];
reach(state => state.phase === 2 && independentDiscoveryBattleClean(state));
const purchased = purchaseOriginalCards(game, actions); game = purchased.game;
reach(state => state.phase === 5 && state.active === 'emperor' && independentDiscoveryBattleClean(state));
const shipments: { before: Game; step: IndependentDiscoveryBattleStep; after: Game }[] = [];
const ship = (actor: string, territory: string, sector: number, amount: number, elite: number) => {
const homeworldSources = game.homeworlds ? nativeShipmentSources(viewGame(game, actor), amount, elite) : undefined;
assert.ok(!game.homeworlds || homeworldSources);
const before = structuredClone(game), step: IndependentDiscoveryBattleStep = { actor, action: {
type: 'ship', territory, sector, amount, elite, allyPayment: 0,
...(homeworldSources ? { homeworldSources } : {}),
} };
act(actor, step.action); reach(independentDiscoveryBattleClean);
shipments.push({ before, step, after: structuredClone(game) });
};
ship('emperor', parent.territory, parent.sector, 3, game.advanced ? 2 : 0);
reach(state => state.phase === 7 && independentDiscoveryBattleClean(state));
const collection = structuredClone(game), finder = viewGame(game, 'emperor').discoveries!;
assert.ok(finder.canInspect.includes(token), 'The actual parent-board finder must inspect the still-hidden original token');
const inspection: IndependentDiscoveryBattleStep = { actor: 'emperor', action: { type: 'discovery', token, reveal: false } };
act(inspection.actor, inspection.action);
const inspected = structuredClone(game);
assert.ok(viewGame(game, 'emperor').discoveries!.canReveal.includes(token));
const reveal: IndependentDiscoveryBattleStep = { actor: 'emperor', action: { type: 'discovery', token, reveal: true } };
act(reveal.actor, reveal.action);
const revealed = structuredClone(game);
reach(state => state.turn === 2 && state.decision?.kind === 'discoveryEntry' &&
state.decision.player === 'emperor' && !state.response && !state.phaseOpening);
const beforeEntry = structuredClone(game), entryView = viewGame(game, 'emperor');
const entryAction = discoveryEntryMoveAction(entryView, entryView.discoveryEntry!.sources); assert.ok(entryAction);
const entry: IndependentDiscoveryBattleStep = { actor: 'emperor', action: entryAction };
act(entry.actor, entry.action); reach(independentDiscoveryBattleClean);
const afterEntry = structuredClone(game);
orderSpice(game, false);
reach(state => state.turn === 2 && state.phase === 5 && state.active === 'guild' && independentDiscoveryBattleClean(state));
ship('guild', JACURUTU_SIETCH, 0, 2, 0);
reach(state => state.phase === 6 && !!state.active && independentDiscoveryBattleClean(state));
const beforeBattle = structuredClone(game), choice = viewGame(game, game.active!).battleChoices
.find(choice => choice.territory === JACURUTU_SIETCH &&
[choice.attacker, choice.defender].includes('emperor') && [choice.attacker, choice.defender].includes('guild'));
assert.ok(choice);
const battle: IndependentDiscoveryBattleStep = { actor: choice.chooser, action: {
type: 'chooseBattle', territory: JACURUTU_SIETCH, target: choice.chooser === 'emperor' ? 'guild' : 'emperor',
} };
act(battle.actor, battle.action);
reach(state => !!state.battle && !state.battle.preparation && state.battle.preLeader?.closed !== false && independentDiscoveryBattleClean(state));
const leaders = (actor: string) => independentDiscoveryBattlePlayer(game, actor).leaders.filter(leader =>
!leader.dead && !leader.usedAt && !game.leaderSkills?.assignments.some(assignment => assignment.leader === leader.id));
const leader = leaders('emperor').sort((a, b) => b.strength - a.strength)[0], enemy = leaders('guild').sort((a, b) => a.strength - b.strength)[0];
assert.ok(leader && enemy);
const fee = viewGame(game, 'emperor').battle!.reinforcements!;
assert.ok(fee && !fee.blocked);
const sources = fee.homeworldSources?.[0]; assert.ok(!game.homeworlds || sources);
const plans: IndependentDiscoveryBattleStep[] = [
{ actor: 'emperor', action: { type: 'battlePlan', leader: leader.id, dial: 1, support: game.advanced ? 1 : 0,
weapon: REINFORCEMENTS_CARD, defense: HARASS_WITHDRAW_CARD,
...(sources ? { reinforcementsSources: sources } : {}) } },
{ actor: 'guild', action: { type: 'battlePlan', leader: enemy.id, dial: 0, support: 0 } },
];
const nested = `${JACURUTU_SIETCH}:0`, returns = { [nested]: { normal: game.advanced ? 0 : 2, elite: game.advanced ? 2 : 0 } };
return { ...source, game, afterSetup, placed, token, parent, purchased, shipments, collection,
inspection, inspected, reveal, revealed, beforeEntry, entry, afterEntry, beforeBattle, battle,
plans, returns, fee: { normal: fee.normal, elite: fee.elite }, sources, nested, actions,
owner: 'emperor', opponent: 'guild', territory: JACURUTU_SIETCH,
staging: ['Only unopened original card lots, Discovery supply/scalar lotteries and Spice positions are selected. Native host, setup, paid shipments, reveal, free entry and battle remain real actions. Nexus is selected but no held Nexus card or effect is asserted.'] };
}
export type IndependentDiscoveryBattleFixture = ReturnType<typeof createIndependentDiscoveryBattleFixture>;
/** Seal the real original plans and finish typed Harass, physical fee and
* casualty settlement. An explicit allocation uses the submitted owner pool. */
export function completeIndependentDiscoveryBattle(fixture: IndependentDiscoveryBattleFixture) {
const actions: IndependentDiscoveryBattleStep[] = [];
let game = structuredClone(fixture.game);
for (const step of fixture.plans) {
game = actIndependentDiscoveryBattle(game, step.actor, step.action, actions);
game = advanceIndependentDiscoveryBattle(game, state => !state.response && !state.phaseOpening &&
(!state.decision || state.decision.kind === 'harassWithdraw') && !state.pendingGuildBetrayal &&
!state.pendingNexusIxianBetrayal && !state.pendingNexusHarkonnenBetrayal, actions);
}
assert.ok(game.battle?.revealed);
const revealed = structuredClone(game);
if (game.decision?.kind === 'harassWithdraw') game = actIndependentDiscoveryBattle(game, game.decision.player,
{ type: 'decision', event: game.decision.event, returns: fixture.returns }, actions);
game = advanceIndependentDiscoveryBattle(game, state => !state.battle && independentDiscoveryBattleClean(state), actions);
return { game, revealed, actions };
}
/** The paid Recruits is used only after the real battle supplied physical
* casualties. Continue the original next-turn clock before its free revival. */
export function reviveIndependentDiscoveryBattle(fixture: IndependentDiscoveryBattleFixture, afterBattle: Game) {
const actions: IndependentDiscoveryBattleStep[] = [];
let game = structuredClone(afterBattle);
// Select only the still-unopened next-turn land replacements before the
// current turn closes; no played blow, discard or encounter is replaced.
orderSpice(game, false);
game = advanceIndependentDiscoveryBattle(game, state => state.turn === fixture.game.turn + 1 &&
state.phase === 4 && independentDiscoveryBattleClean(state), actions);
const before = structuredClone(game), play = recruitsPlayAction(viewGame(game, fixture.owner).recruitsPreview);
assert.ok(play, 'The genuinely purchased Recruits needs its original Revival control');
game = actIndependentDiscoveryBattle(game, fixture.owner, play, actions);
game = advanceIndependentDiscoveryBattle(game, independentDiscoveryBattleClean, actions);
const activated = structuredClone(game), revival: Action = { type: 'revive', amount: 2, elite: 0 };
game = actIndependentDiscoveryBattle(game, fixture.owner, revival, actions);
game = advanceIndependentDiscoveryBattle(game, independentDiscoveryBattleClean, actions);
const revived = structuredClone(game);
game = advanceIndependentDiscoveryBattle(game, state => state.phase === 5 && independentDiscoveryBattleClean(state), actions);
return { game, before, activated, revived, play, revival, actions };
}
/** Minimal legal positive native card plans from all four real owner views. */
export function smokeIndependentDiscoveryBattle(options: Partial<IndependentDiscoveryBattleOptions> = {}) {
const fixture = createIndependentDiscoveryBattleFixture(options);
return (['Easy', 'Medium', 'Hard', 'Brutal'] as const).map(difficulty => {
const view = viewGame(fixture.game, fixture.owner);
view.players.find(player => player.id === fixture.owner)!.bot = difficulty;
const action = botActions(view).find(action => action.type === 'battlePlan' && Number(action.dial) > 0 &&
(action.weapon === REINFORCEMENTS_CARD || action.defense === REINFORCEMENTS_CARD));
assert.ok(action, `${difficulty} must retain a positive native Reinforcements plan at the nested site`);
assert.ok(!fixture.game.homeworlds || action.reinforcementsSources);
const game = actIndependentDiscoveryBattle(structuredClone(fixture.game), fixture.owner, action);
return { difficulty, plan: game.battle!.plans[fixture.owner], sources: action.reinforcementsSources };
});
}
@@ -0,0 +1,198 @@
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { DISCOVERY_CARD_PLACEMENTS } from '../game/discoveries';
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
import { nativeShipmentSources } from '../game/homeworld-options';
import {
createSelectedIndependentEntry, type SelectedIndependentEntryOptions,
} from './fixture-selected-independent-nexus-entry';
import {
withClassicDiscoveryNexusLottery, withClassicDiscoveryNexusToken,
} from './fixture-discovery-classic-nexus';
import {
fremenCunningModulesClean, fremenCunningModulesPlayer, nextFremenCunningModulesStep,
type FremenCunningModulesStep,
} from './fixture-fremen-cunning-modules';
export type IndependentDiscoveryCardsOptions = Partial<Omit<SelectedIndependentEntryOptions, 'discoveries'>>;
export type IndependentDiscoveryCardsStep = FremenCunningModulesStep;
export const independentDiscoveryCardsPlayer = fremenCunningModulesPlayer;
export const independentDiscoveryCardsClean = fremenCunningModulesClean;
export function actIndependentDiscoveryCards(game: Game, actor: string, action: Action,
actions?: IndependentDiscoveryCardsStep[]): Game {
actions?.push({ actor, action: structuredClone(action) });
return withClassicDiscoveryNexusLottery(() => applyAction(game, actor, action));
}
export function nextIndependentDiscoveryCardsStep(game: Game): IndependentDiscoveryCardsStep | null {
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) {
const decision = game.decision;
if (decision?.kind === 'discoveryEntry' || decision?.kind === 'greatMakerRide')
return { actor: decision.player, action: { type: 'decision', event: decision.event, accept: false } };
if (decision?.kind === 'greatMakerVote')
return { actor: decision.player, action: { type: 'decision', event: decision.event, yes: false } };
if (decision?.kind === 'discoveryDiscard') {
const card = independentDiscoveryCardsPlayer(game, decision.player).hand[0]; assert.ok(card);
return { actor: decision.player, action: { type: 'decision', event: decision.event, card: card.id } };
}
}
return nextFremenCunningModulesStep(game);
}
export function advanceIndependentDiscoveryCards(game: Game, until: (state: Game) => boolean,
actions: IndependentDiscoveryCardsStep[] = []): Game {
for (let count = 0; count < 2500; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original independent Discovery chronology ended early');
const next = nextIndependentDiscoveryCardsStep(game); assert.ok(next,
`Choose the original ${game.decision?.kind ?? 'Battle Plan'} at ${game.turn}/${game.phase}`);
game = actIndependentDiscoveryCards(game, next.actor, next.action, actions);
}
throw Error('The original independent Discovery chronology did not reach its requested boundary');
}
/** Only original unopened Spice Cards are positioned. A Discovery card uses
* its real printed parent placement and ordinary production, not a saved flag. */
function orderOriginalSpice(game: Game, entries: readonly string[]): void {
const front: Game['spiceDeck'] = [];
for (const entry of entries) {
const index = game.spiceDeck.findIndex(card => entry === 'worm'
? 'worm' in card && !card.greatMaker && !card.thumper && !card.suppressed
: entry === 'sandtrout' ? 'sandtrout' in card
: entry.startsWith('discovery-') ? 'territory' in card && card.discovery === entry
: 'territory' in card && !card.discovery && card.territory === entry);
assert.ok(index >= 0, `Use original unopened Spice Card ${entry}`);
front.push(game.spiceDeck.splice(index, 1)[0]);
}
game.spiceDeck.unshift(...front);
}
function nativeShipment(game: Game, actor: string, territory: string, sector: number, amount: number, elite = 0): Action {
const sources = game.homeworlds ? nativeShipmentSources(viewGame(game, actor), amount, elite) : undefined;
assert.ok(!game.homeworlds || sources);
return { type: 'ship', territory, sector, amount, elite, allyPayment: 0,
...(sources ? { homeworldSources: sources } : {}) };
}
/** Original host modules/setup → physical Discovery placement → paid parent
* shipment → private inspection/public reveal → actual next-turn free-entry
* window. Fremen separately earns its remote source through native shipment.
* No hand, purse, force, clock, token custody or receipt is assigned afterward. */
export function createIndependentDiscoveryCardsFixture(options: IndependentDiscoveryCardsOptions = {}) {
const source = withClassicDiscoveryNexusLottery(() => createSelectedIndependentEntry({
...options, expansions: options.expansions ?? [], factions: options.factions ?? ['fremen', 'guild', 'emperor'], discoveries: true,
}));
let game = source.game;
const actions = [...source.actions], afterSetup = structuredClone(game), staging: string[] = [];
assert.ok(game.discoveryEnabled && game.discoveries && game.ecazTreachery && game.nexusCards?.cards);
for (const actor of ['fremen', 'guild', 'emperor']) assert.ok(game.players.some(player => player.id === actor));
const act = (actor: string, action: Action) => { game = actIndependentDiscoveryCards(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceIndependentDiscoveryCards(game, until, actions); };
orderOriginalSpice(game, game.advanced ? ['discovery-hagga-basin', 'broken_land'] : ['discovery-hagga-basin']);
reach(state => state.phase === 1 && independentDiscoveryCardsClean(state));
for (let count = 0; count < 100 && !game.discoveries!.tokens.some(token => token.face === 'cistern' && token.status === 'placed'); count++) {
const next = nextIndependentDiscoveryCardsStep(game); assert.ok(next);
actions.push(structuredClone(next));
// This original supply-token lottery must surround the actual producer,
// outside the generic scalar wrapper used for all other actions.
game = withClassicDiscoveryNexusToken(game, 'cistern', () => applyAction(game, next.actor, next.action));
}
const token = game.discoveries!.tokens.find(token => token.face === 'cistern' && token.status === 'placed'); assert.ok(token);
const placed = structuredClone(game), parent = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'];
assert.equal(token.territory, parent.territory); assert.equal(token.sector, parent.sector);
const shipments: Array<{ actor: string; before: Game; step: IndependentDiscoveryCardsStep; after: Game }> = [];
const shipped = new Set<string>();
for (let count = 0; count < 800; count++) {
if (game.phase === 7 && independentDiscoveryCardsClean(game)) break;
if (game.phase === 5 && independentDiscoveryCardsClean(game) && game.active &&
['fremen', 'guild'].includes(game.active) && !shipped.has(game.active)) {
const actor = game.active, before = structuredClone(game);
const action = actor === 'fremen' ? nativeShipment(game, actor, 'wind_pass', 14, 3, game.advanced ? 2 : 0)
: nativeShipment(game, actor, parent.territory, parent.sector, 3);
const step: IndependentDiscoveryCardsStep = { actor, action };
act(actor, action); reach(independentDiscoveryCardsClean);
shipments.push({ actor, before, step, after: structuredClone(game) }); shipped.add(actor);
continue;
}
const next = nextIndependentDiscoveryCardsStep(game); assert.ok(next); act(next.actor, next.action);
}
assert.equal(shipped.size, 2);
assert.equal(game.phase, 7);
const beforeInspection = structuredClone(game), own = viewGame(game, 'guild').discoveries; assert.ok(own);
assert.ok(own.canInspect.includes(token.id));
const inspect: IndependentDiscoveryCardsStep = { actor: 'guild', action: { type: 'discovery', token: token.id, reveal: false } };
act(inspect.actor, inspect.action);
const inspected = structuredClone(game);
assert.ok(viewGame(game, 'guild').discoveries!.canReveal.includes(token.id));
const reveal: IndependentDiscoveryCardsStep = { actor: 'guild', action: { type: 'discovery', token: token.id, reveal: true } };
act(reveal.actor, reveal.action);
const revealed = structuredClone(game);
reach(state => state.turn === 2 && !state.phaseOpening && !state.response && state.decision?.kind === 'discoveryEntry' && state.decision.player === 'guild');
staging.push('Original host independently selects all modules; only unopened Spice order and the original Cistern supply-token lottery are positioned. Real native Fremen reinforcement and paid Guild parent shipment precede actual inspection, reveal and next-turn entry.');
return { source, afterSetup, game, actions, staging, token: token.id, parent: parent.territory, parentSector: parent.sector,
placed, shipments, beforeInspection, inspect, inspected, reveal, revealed,
owner: 'fremen', collector: 'guild', sourceKey: 'wind_pass:14', destination: 'cistern' };
}
export type IndependentDiscoveryCardsFixture = ReturnType<typeof createIndependentDiscoveryCardsFixture>;
/** Continue the original free-entry choice, then a real ordinary worm,
* reciprocal alliance and actual closing draw. The common source is returned
* immediately before its next physical Spice production. */
function openIndependentDiscoveryCardsDraw(fixture: IndependentDiscoveryCardsFixture) {
let game = fixture.game;
const actions = fixture.actions, owner = fixture.owner;
const entryBefore = structuredClone(game), own = viewGame(game, 'guild'), available = own.discoveryEntry!.sources.find(source => source.normal >= 1); assert.ok(available);
const entryAction = discoveryEntryMoveAction(own, [{ source: available.source, normal: 1, elite: 0 }]); assert.ok(entryAction);
const entry: IndependentDiscoveryCardsStep = { actor: 'guild', action: entryAction };
game = actIndependentDiscoveryCards(game, entry.actor, entry.action, actions);
const entered = structuredClone(game);
game = advanceIndependentDiscoveryCards(game, state => state.turn === 2 && state.phase === 1 && independentDiscoveryCardsClean(state), actions);
orderOriginalSpice(game, game.advanced ? ['worm', 'red_chasm', 'rock_outcroppings'] : ['worm', 'red_chasm']);
game = advanceIndependentDiscoveryCards(game, state => state.nexus && !state.spiceWindow && !state.spiceResolution && independentDiscoveryCardsClean(state), actions);
const beforeAlliance = structuredClone(game);
for (const [actor, target] of [['guild', 'emperor'], ['emperor', 'guild']])
game = actIndependentDiscoveryCards(game, actor, { type: 'alliance', target }, actions);
game = advanceIndependentDiscoveryCards(game, state => state.nexusCards?.phase?.stage === 'drawing' && independentDiscoveryCardsClean(state), actions);
const deck = game.nexusCards!.cards!.deck, index = deck.indexOf('fremen'); assert.ok(index >= 0);
deck.unshift(deck.splice(index, 1)[0]);
const drawBefore = structuredClone(game), draw: IndependentDiscoveryCardsStep = { actor: owner, action: {
type: 'nexusCardChoice', turn: game.turn, card: null, choice: 'draw', ownRedraws: 0 } };
game = actIndependentDiscoveryCards(game, draw.actor, draw.action, actions);
const drawn = structuredClone(game);
game = advanceIndependentDiscoveryCards(game, state => state.turn === 3 && state.phase === 1 && independentDiscoveryCardsClean(state), actions);
return { game, actions, entryBefore, entry, entered, beforeAlliance, drawBefore, draw, drawn };
}
/** Spend the actually drawn own card and take its typed post-Nexus remote ride
* into the revealed nested location, retaining native destination admission. */
export function completeIndependentDiscoveryCards(fixture: IndependentDiscoveryCardsFixture) {
const opened = openIndependentDiscoveryCardsDraw(fixture), actions = opened.actions;
let game = opened.game;
orderOriginalSpice(game, game.advanced ? ['worm', 'sihaya_ridge', 'habbanya_erg'] : ['worm', 'sihaya_ridge']);
game = advanceIndependentDiscoveryCards(game, state => !state.response && !state.phaseOpening && state.decision?.kind === 'nexusFremenCunningOffer', actions);
const beforeSpend = structuredClone(game), occurrence = structuredClone(game.nexusFremenCunningOffer!.occurrence);
const spend: IndependentDiscoveryCardsStep = { actor: fixture.owner, action: { type: 'decision', event: occurrence.event, accept: true } };
game = actIndependentDiscoveryCards(game, spend.actor, spend.action, actions);
const spent = structuredClone(game);
game = advanceIndependentDiscoveryCards(game, state => !state.response && !state.phaseOpening && state.decision?.kind === 'nexusFremenCunningRide', actions);
const beforeRide = structuredClone(game), source = independentDiscoveryCardsPlayer(game, fixture.owner);
const elite = game.advanced ? 2 : 0;
assert.ok(source.forces[fixture.sourceKey] >= 3 && (source.elites?.forces[fixture.sourceKey] ?? 0) >= elite);
const ride: IndependentDiscoveryCardsStep = { actor: fixture.owner, action: { type: 'decision', event: occurrence.event,
accept: true, source: 'wind_pass', forces: { [fixture.sourceKey]: { normal: 3 - elite, elite } }, territory: fixture.destination, sector: 0 } };
game = actIndependentDiscoveryCards(game, ride.actor, ride.action, actions);
const afterRide = structuredClone(game);
game = advanceIndependentDiscoveryCards(game, independentDiscoveryCardsClean, actions);
return { ...opened, game, actions, occurrence, beforeSpend, spend, spent, beforeRide, ride, afterRide };
}
/** The original E1 Sandtrout breaks the actual alliance, suppresses the next
* original worm and doubles only its immediate original land replacement. */
export function completeIndependentDiscoverySandtrout(fixture: IndependentDiscoveryCardsFixture) {
assert.ok(fixture.afterSetup.expansions.includes('ix'));
const opened = openIndependentDiscoveryCardsDraw(fixture), actions = opened.actions;
let game = opened.game;
const before = structuredClone(game);
orderOriginalSpice(game, game.advanced ? ['sandtrout', 'worm', 'sihaya_ridge', 'habbanya_erg'] : ['sandtrout', 'worm', 'sihaya_ridge']);
game = advanceIndependentDiscoveryCards(game, state => state.spiceWindow?.territory === 'sihaya_ridge', actions);
const doubled = structuredClone(game);
game = advanceIndependentDiscoveryCards(game, state => state.phase === 2 && independentDiscoveryCardsClean(state), actions);
return { ...opened, game, actions, before, doubled };
}
@@ -11,6 +11,8 @@ export type SelectedIndependentEntryOptions = {
homeworlds?: boolean;
tech?: boolean;
strongholds?: boolean;
discoveries?: boolean;
nexus?: boolean;
};
/** Ordinary host controls and original private choices; no dealt inventory edits. */
@@ -24,7 +26,8 @@ export function createSelectedIndependentEntry(options: SelectedIndependentEntry
game = applyAction(game, actor, action);
};
act(host, {type:'moduleOption', module:'ecazTreachery', enabled:true});
act(host, {type:'moduleOption', module:'nexusCards', enabled:true});
if (options.nexus !== false) act(host, {type:'moduleOption', module:'nexusCards', enabled:true});
if (options.discoveries) act(host, {type:'moduleOption', module:'discoveries', enabled:true});
if (options.skills) act(host, {type:'moduleOption', module:'leaderSkills', enabled:true});
if (options.homeworlds) act(host, {type:'homeworlds', enabled:true});
if (options.tech) act(host, {type:'techTokens', enabled:true});
+116
View File
@@ -0,0 +1,116 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import type { Game } from '../game/engine';
import { homeworldContext } from '../game/homeworld-game';
import { homeworldForceGroups } from '../game/homeworld-custody';
import { JACURUTU_SIETCH } from '../game/discoveries';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
import { RECRUITS_CARD_ID } from '../game/recruits';
import {
completeIndependentDiscoveryBattle, createIndependentDiscoveryBattleFixture,
independentDiscoveryBattlePlayer as player, reviveIndependentDiscoveryBattle,
smokeIndependentDiscoveryBattle, type IndependentDiscoveryBattleFixture,
} from './fixture-independent-discovery-battles';
function nativePool(game: Game, world: string) {
return homeworldForceGroups(homeworldContext(game), game.homeworlds!.custody!)
.find(group => group.id === world)!.forces.emperor;
}
function originalSource(f: IndependentDiscoveryBattleFixture) {
assert.equal(f.placed.discoveries!.tokens.find(token => token.id === f.token)!.face, JACURUTU_SIETCH);
assert.equal(f.placed.discoveries!.tokens.find(token => token.id === f.token)!.revealedTurn, null);
assert.ok(f.inspected.discoveries!.tokens.find(token => token.id === f.token)!.known.includes('emperor'));
assert.equal(f.revealed.discoveries!.tokens.find(token => token.id === f.token)!.revealedTurn, 1);
assert.equal(f.purchased.bids.length, 3);
assert.ok(f.purchased.bids.every(bid => bid.step.actor === 'emperor' && bid.step.action.amount === 1));
for (const id of [REINFORCEMENTS_CARD, HARASS_WITHDRAW_CARD, RECRUITS_CARD_ID])
assert.ok(player(f.game, f.owner).hand.some(card => card.id === id), 'Only original actually purchased cards reach the battle');
const prior = player(f.beforeEntry, f.owner), entered = player(f.afterEntry, f.owner);
assert.equal(entered.forces[f.nested], 3);
assert.equal(entered.forces[`${f.parent.territory}:${f.parent.sector}`] ?? 0, 0);
assert.equal(entered.reserves, prior.reserves);
assert.equal(entered.spice, prior.spice);
assert.equal(entered.moved, prior.moved);
assert.equal(entered.shipped, prior.shipped, 'The real next-turn nested entry consumes no ordinary shipment');
assert.deepEqual(f.afterEntry.homeworlds, f.beforeEntry.homeworlds);
assert.equal(player(f.game, f.opponent).forces[f.nested], 2);
assert.equal(f.shipments[1].step.action.territory, JACURUTU_SIETCH);
assert.ok(player(f.shipments[1].after, f.opponent).spice < player(f.shipments[1].before, f.opponent).spice,
'The enemy army uses its actual printed paid nested shipment');
assert.deepEqual(f.fee, { normal: 3, elite: 0 });
assert.equal(f.game.nexusCards?.cards?.hands.emperor ?? null, null);
assert.equal(f.game.nexusCards?.cards?.hands.guild ?? null, null, 'Selected Nexus is not a claim that an undrawn card was earned');
}
function settled(f: IndependentDiscoveryBattleFixture, game: Game) {
const before = player(f.game, f.owner), after = player(game, f.owner);
assert.equal(game.lastBattleContext!.territory, JACURUTU_SIETCH);
assert.equal(game.lastBattleContext!.winner, f.owner);
assert.equal(game.lastBattleContext!.result, 'normal');
assert.equal(after.forces[f.nested] ?? 0, 0);
assert.equal(after.reserves, before.reserves - 3 + 2);
assert.equal(after.tanks, before.tanks + 4, 'Three actual reserve-fee counters and one dialed normal casualty reach Tanks');
assert.equal(after.elites?.tanks ?? 0, before.elites?.tanks ?? 0);
assert.equal(after.elites?.reserves ?? 0, (before.elites?.reserves ?? 0) + (game.advanced ? 2 : 0));
assert.equal(player(game, f.opponent).tanks, player(f.game, f.opponent).tanks + 2);
for (const id of [REINFORCEMENTS_CARD, HARASS_WITHDRAW_CARD])
assert.equal(game.discard.filter(card => card.id === id).length, 1);
assert.ok(after.hand.some(card => card.id === RECRUITS_CARD_ID));
}
for (const nexus of [true, false]) void test(`original Basic classic Discovery placement${nexus ? ' with Nexus' : ' without Nexus'}, inspection and free nested entry feed real Reinforcements and Harass battle settlement`, () => {
const f = createIndependentDiscoveryBattleFixture({ advanced: false, expansions: [],
homeworlds: false, skills: false, tech: false, strongholds: false, nexus });
originalSource(f);
assert.equal(f.game.discoveryEnabled, true);
assert.equal(f.game.ecazTreachery, true);
assert.equal(!!f.game.nexusCards, nexus);
assert.equal(f.game.turn, 2);
assert.equal(f.game.battle!.territory, JACURUTU_SIETCH);
const result = completeIndependentDiscoveryBattle(f);
assert.equal(result.revealed.battle!.plans.emperor.weapon, REINFORCEMENTS_CARD);
assert.equal(result.revealed.battle!.plans.emperor.defense, HARASS_WITHDRAW_CARD);
settled(f, result.game);
});
void test('original selected-family World/all14/phase Discovery battle pays native reserve fee, returns Sardaukar and revives actual losses through paid Recruits', () => {
const f = createIndependentDiscoveryBattleFixture({ advanced: true, expansions: ['ix', 'choam', 'ecaz'],
homeworlds: true, skills: true, tech: true, strongholds: true });
originalSource(f);
assert.ok(f.game.homeworlds && f.game.leaderSkills && f.game.techTokens && f.game.strongholdCards);
assert.equal(player(f.afterEntry, f.owner).elites!.forces[f.nested], 2);
assert.ok(f.plans[0].action.reinforcementsSources);
const { game } = completeIndependentDiscoveryBattle(f);
settled(f, game);
assert.deepEqual(nativePool(game, 'homeworld:emperor'), {
normal: nativePool(f.game, 'homeworld:emperor').normal - 3,
elite: nativePool(f.game, 'homeworld:emperor').elite,
});
assert.deepEqual(nativePool(game, 'homeworld:emperor:salusa'), {
normal: nativePool(f.game, 'homeworld:emperor:salusa').normal,
elite: nativePool(f.game, 'homeworld:emperor:salusa').elite + 2,
});
const revived = reviveIndependentDiscoveryBattle(f, game);
assert.equal(revived.before.turn, 3);
assert.equal(revived.before.phase, 4);
assert.equal(player(revived.before, f.owner).tanks, 4);
assert.equal(revived.activated.recruits!.player, f.owner);
assert.equal(revived.activated.recruits!.turn, 3);
assert.equal(revived.revived.discard.filter(card => card.id === RECRUITS_CARD_ID).length, 1);
assert.equal(player(revived.revived, f.owner).tanks, 2);
assert.equal(player(revived.revived, f.owner).reserves, player(revived.activated, f.owner).reserves + 2);
assert.equal(player(revived.revived, f.owner).spice, player(revived.activated, f.owner).spice,
'The doubled printed free allowance pays no invented income or extra invoice');
assert.deepEqual(nativePool(revived.revived, 'homeworld:emperor'), {
normal: nativePool(revived.activated, 'homeworld:emperor').normal + 2,
elite: nativePool(revived.activated, 'homeworld:emperor').elite,
});
assert.equal(revived.game.phase, 5);
});
void test('all four minimal policies retain source-bearing positive Reinforcements plans in a genuinely revealed nested Discovery battle', () => {
const report = smokeIndependentDiscoveryBattle({ advanced: true, expansions: ['ix', 'choam', 'ecaz'],
homeworlds: true, skills: true, tech: true, strongholds: true });
assert.equal(report.length, 4);
assert.ok(report.every(result => result.plan.dial > 0 && result.sources));
});
+96
View File
@@ -0,0 +1,96 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Game } from '../game/engine';
import { independentDiscoveryNexusProfile } from '../game/faction-module-profile';
import { validateDiscoveryState } from '../game/discoveries';
import { NEXUS_FACTIONS } from '../game/nexus-cards';
import {
createIndependentDiscoveryCardsFixture, completeIndependentDiscoveryCards,
completeIndependentDiscoverySandtrout, independentDiscoveryCardsPlayer as player,
} from './fixture-independent-discovery-cards';
function originalModules(game: Game): void {
assert.ok(independentDiscoveryNexusProfile(game));
assert.ok(game.ecazTreachery && game.discoveryEnabled && game.discoveries && game.nexusCards?.cards);
validateDiscoveryState(game.discoveries);
assert.equal(game.discoveries.tokens.length, 8);
const cards = game.nexusCards.cards;
assert.deepEqual([...cards.deck, ...cards.discard, ...Object.values(cards.hands).filter(card => card !== null)].sort(), [...NEXUS_FACTIONS].sort());
}
for (const modules of [false, true]) void test(`${modules ? 'Advanced six seats across three families with World/all14/phase' : 'Basic classic independent'} original Discovery reveal and next-turn entry precede native Fremen Cunning into its revealed nested location`, () => {
const fixture = createIndependentDiscoveryCardsFixture({ advanced: modules,
expansions: modules ? ['ix', 'choam', 'ecaz'] : [],
factions: modules ? ['fremen', 'guild', 'emperor', 'ixians', 'choam', 'ecaz'] : ['fremen', 'guild', 'emperor'],
skills: modules, homeworlds: modules, tech: modules, strongholds: modules });
originalModules(fixture.afterSetup);
assert.equal(!!fixture.afterSetup.homeworlds, modules);
assert.equal(!!fixture.afterSetup.leaderSkills, modules);
assert.equal(!!fixture.afterSetup.techTokens, modules);
assert.equal(!!fixture.afterSetup.strongholdCards, modules);
assert.equal(viewGame(fixture.beforeInspection, 'guild').discoveries!.tokens.find(token => token.id === fixture.token)!.face, null);
assert.equal(viewGame(fixture.inspected, 'guild').discoveries!.tokens.find(token => token.id === fixture.token)!.face, 'cistern');
assert.equal(viewGame(fixture.inspected, 'emperor').discoveries!.tokens.find(token => token.id === fixture.token)!.face, null);
assert.equal(viewGame(fixture.beforeInspection, 'fremen').discoveries!.tokens.find(token => token.id === fixture.token)!.face, 'cistern',
'Native Fremen already knows the Hiereg face.');
assert.equal(viewGame(fixture.revealed, 'fremen').discoveries!.tokens.find(token => token.id === fixture.token)!.face, 'cistern');
assert.equal(fixture.revealed.discoveries!.tokens.find(token => token.id === fixture.token)!.revealedTurn, 1);
assert.equal(fixture.game.turn, 2);
assert.equal(fixture.game.phase, 0);
assert.equal(fixture.game.decision?.kind, 'discoveryEntry');
const guild = fixture.shipments.find(shipment => shipment.actor === 'guild'); assert.ok(guild);
assert.equal(player(guild.after, 'guild').reserves, player(guild.before, 'guild').reserves - 3);
assert.equal(player(guild.after, 'guild').spice, player(guild.before, 'guild').spice - 3);
const done = completeIndependentDiscoveryCards(fixture);
assert.equal(player(done.entered, 'guild').forces['cistern:0'], 1);
assert.equal(player(done.entered, 'guild').forces[`${fixture.parent}:${fixture.parentSector}`], player(done.entryBefore, 'guild').forces[`${fixture.parent}:${fixture.parentSector}`] - 1);
assert.equal(player(done.entered, 'guild').spice, player(done.entryBefore, 'guild').spice);
assert.equal(player(done.entered, 'guild').moved, player(done.entryBefore, 'guild').moved);
assert.equal(player(done.entered, 'guild').shipped, player(done.entryBefore, 'guild').shipped);
assert.equal(done.drawn.nexusCards!.cards!.hands.fremen, 'fremen');
assert.equal(player(done.drawn, 'guild').ally, 'emperor');
assert.equal(player(done.drawn, 'fremen').ally, null);
assert.equal(done.occurrence.initiallyEmpty, true);
assert.equal(done.occurrence.territory, 'red_chasm');
assert.equal(done.occurrence.origin, 'natural');
assert.equal(done.beforeSpend.spice['red_chasm:7'], 8);
assert.equal(done.spent.nexusCards!.cards!.hands.fremen, null);
assert.equal(done.spent.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1);
assert.equal(done.beforeRide.spice['red_chasm:7'] ?? 0, 0);
assert.equal(done.beforeRide.decision?.kind, 'nexusFremenCunningRide');
assert.equal(player(done.afterRide, 'fremen').forces['cistern:0'], 3);
assert.equal(player(done.afterRide, 'fremen').elites?.forces['cistern:0'] ?? 0, modules ? 2 : 0);
assert.equal(player(done.afterRide, 'fremen').forces[fixture.sourceKey] ?? 0, player(done.beforeRide, 'fremen').forces[fixture.sourceKey] - 3);
assert.deepEqual(player(done.afterRide, 'guild').forces, player(done.beforeRide, 'guild').forces);
assert.equal(player(done.afterRide, 'fremen').reserves, player(done.beforeRide, 'fremen').reserves);
assert.equal(player(done.afterRide, 'fremen').moved, player(done.beforeRide, 'fremen').moved);
assert.equal(player(done.afterRide, 'fremen').shipped, player(done.beforeRide, 'fremen').shipped);
assert.equal(player(done.afterRide, 'fremen').spice, player(done.beforeRide, 'fremen').spice);
assert.equal(done.afterRide.nexusFremenCunningRides!.find(ride => ride.occurrence.event === done.occurrence.event)!.stage, 'complete');
originalModules(done.game);
});
void test('actual selected Ix Sandtrout breaks the earned alliance and suppresses its next worm without losing independent Discovery or native Fremen card custody', () => {
const fixture = createIndependentDiscoveryCardsFixture({ expansions: ['ix'], factions: ['fremen', 'guild', 'emperor'] });
const done = completeIndependentDiscoverySandtrout(fixture);
assert.equal(player(done.before, 'guild').ally, 'emperor');
assert.equal(player(done.before, 'emperor').ally, 'guild');
assert.equal(done.before.nexusCards!.cards!.hands.fremen, 'fremen');
assert.ok(done.doubled.players.every(player => player.ally === null));
assert.equal(done.doubled.sandtrout, false);
assert.equal(done.doubled.nexus, false);
assert.equal(done.doubled.nexusFremenCunningOffer ?? null, null);
assert.equal(done.doubled.nexusFremenCunningRides?.length ?? 0, done.before.nexusFremenCunningRides?.length ?? 0);
assert.equal(done.doubled.spiceWindow!.amount, 12);
assert.deepEqual(done.doubled.spiceDiscard[0].slice(-3), [
{ sandtrout: true }, { worm: true, suppressed: true }, { territory: 'sihaya_ridge', amount: 6, sector: 9 },
]);
assert.equal(done.doubled.nexusCards!.cards!.hands.fremen, 'fremen');
assert.equal(done.doubled.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 0);
assert.deepEqual(player(done.doubled, 'fremen').forces, player(done.before, 'fremen').forces);
assert.equal(player(done.doubled, 'fremen').tanks, player(done.before, 'fremen').tanks);
assert.equal(done.doubled.spice['red_chasm:7'], done.before.spice['red_chasm:7']);
assert.equal(done.game.phase, 2);
assert.equal(done.game.nexusCards!.phase!.occurred, false);
assert.equal(done.game.spice['sihaya_ridge:9'], 12);
originalModules(done.game);
});
@@ -0,0 +1,150 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { spiceDeck } from '../game/cards';
import { DISCOVERY_SPICE_CARDS, validateDiscoveryState } from '../game/discoveries';
import { ECAZ_TREACHERY_DEFINITIONS } from '../game/ecaz-cards';
import { independentDiscoveryProfile, independentDiscoveryNexusProfile, independentNexusProfile, nexusCoreProfile } from '../game/faction-module-profile';
import { createTechTokens } from '../game/tech-tokens';
import { createSelectedIndependentEntry, type SelectedIndependentEntryOptions } from './fixture-selected-independent-nexus-entry';
const entries: SelectedIndependentEntryOptions[] = [
{ expansions: [], factions: ['atreides', 'guild'] },
{ expansions: ['ix'], factions: ['ixians', 'tleilaxu'] },
{ expansions: ['choam'], factions: ['choam', 'richese', 'guild'], advanced: true,
skills: true, homeworlds: true, tech: true, strongholds: true },
{ expansions: ['ecaz'], factions: ['ecaz', 'moritani'] },
{ expansions: ['ecaz', 'ix', 'choam'], factions: ['ecaz', 'moritani', 'ixians', 'tleilaxu', 'choam', 'richese'],
advanced: true, skills: true, homeworlds: true, tech: true, strongholds: true },
{ expansions: ['ix', 'choam', 'ecaz'], factions: ['emperor', 'atreides'],
advanced: true, skills: true, strongholds: true },
];
for (const options of [entries[0], entries[4]]) void test(
`normal independent Discovery keeps Nexus optional for ${options.expansions.join('+') || 'classic'}`,
() => {
const { game, actions } = createSelectedIndependentEntry({ ...options, discoveries: true, nexus: false });
assert.ok(independentDiscoveryProfile(game));
assert.equal(independentDiscoveryNexusProfile(game), false);
assert.equal(nexusCoreProfile(game), false);
assert.equal(game.nexusCards, undefined);
assert.ok(game.discoveries);
validateDiscoveryState(game.discoveries);
assert.equal(game.discoveries.tokens.length, 8);
assert.ok(!actions.some(step => step.action.type === 'moduleOption' && step.action.module === 'nexusCards'));
assert.equal(game.nexusIxianReplacementPreview, undefined);
assert.equal(game.nexusIxianBetrayalPreview, undefined);
assert.equal(game.guildBetrayalPreview, undefined);
assert.equal(!!game.leaderSkills, !!options.skills);
assert.equal(!!game.homeworlds, !!options.homeworlds);
},
);
for (const options of entries) void test(
`normal ${options.advanced ? 'Advanced' : 'Basic'} independent Discovery Nexus setup ${options.expansions.join('+') || 'classic'}/${options.factions.join('+')}`,
() => {
const { lobby, setup, game, actions } = createSelectedIndependentEntry({ ...options, discoveries: true });
assert.ok(actions.some(step => step.actor === lobby.host && step.action.type === 'moduleOption' &&
step.action.module === 'discoveries' && step.action.enabled === true));
assert.equal(lobby.discoveryEnabled, true);
assert.equal(lobby.lobbyOptions?.ecazTreachery, true);
assert.equal(lobby.discoveries, undefined, 'The host selects a module before any token is dealt.');
assert.equal(game.discoveryEnabled, true);
assert.equal(game.ecazTreachery, true);
assert.equal(independentDiscoveryNexusProfile(setup), true);
assert.equal(independentDiscoveryNexusProfile(game), true);
assert.equal(independentNexusProfile(game), true);
assert.equal(nexusCoreProfile(game), true);
assert.deepEqual(game.expansions, options.expansions);
assert.equal(game.status, 'playing');
assert.equal(game.phase, 0);
assert.equal(!!game.homeworlds, !!options.homeworlds);
assert.equal(!!game.leaderSkills, !!options.skills);
assert.equal(!!game.techTokens, !!options.tech);
assert.equal(!!game.strongholdCards, !!options.strongholds);
const ordinary = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand)];
assert.equal(ordinary.length, options.expansions.includes('ix') ? 50 : options.expansions.includes('choam') ? 38 : 36);
for (const definition of ECAZ_TREACHERY_DEFINITIONS)
assert.equal(ordinary.filter(card => card.id === definition.card.id).length, 1,
`${definition.card.name} remains one real selected independent card.`);
assert.equal(game.richeseCache?.length ?? 0, options.factions.includes('richese') ? 10 : 0);
assert.equal(game.nexusCards!.cards!.deck.length, 12);
assert.equal(game.nexusCards!.cards!.discard.length, 0);
assert.ok(Object.values(game.nexusCards!.cards!.hands).every(card => card === null),
'Fresh module setup does not invent a qualifying closing Nexus draw.');
assert.ok(game.discoveries);
validateDiscoveryState(game.discoveries);
assert.equal(game.discoveries.tokens.length, 8);
assert.ok(game.discoveries.tokens.every(token => token.status === 'supply' &&
token.territory === null && token.sector === null && token.revealedTurn === null));
assert.deepEqual(game.discoveries.newlyRevealed, []);
assert.equal(game.spiceDeck.length, spiceDeck(options.expansions.includes('ix')).length + 7);
assert.deepEqual(game.spiceDeck.flatMap(card => 'territory' in card && card.discovery ? [card.discovery] : []).sort(),
DISCOVERY_SPICE_CARDS.map(card => card.discovery).sort());
assert.equal(game.spiceDeck.filter(card => 'worm' in card && card.greatMaker).length, 1);
assert.equal(game.spiceDeck.filter(card => 'sandtrout' in card).length, options.expansions.includes('ix') ? 1 : 0);
assert.equal(game.greatMaker, undefined, 'No appearance occurs during fresh Storm setup.');
assert.equal(!!game.nexusIxianBetrayalPreview, options.factions.includes('ixians'));
assert.equal(!!game.nexusIxianReplacementPreview, !options.factions.includes('ixians'));
assert.equal(!!game.ecazOccupyPreview, options.factions.includes('ecaz'));
assert.equal(!!game.moritaniAssassinate, !!options.advanced && options.factions.includes('moritani'));
if (options.factions.includes('richese'))
assert.equal(game.players.find(player => player.faction === 'richese')!.noField!.tokens.length, 3);
if (options.factions.includes('tleilaxu'))
assert.ok(game.players.find(player => player.faction === 'tleilaxu')!.faceDancers?.length,
'The original Tleilaxu private setup remains connected.');
if (options.skills) assert.ok(['skillTreachery', 'leaderSkills'].includes(setup.setupStage!));
if (setup.techTokens) assert.deepEqual(setup.techTokens, createTechTokens(setup.players));
if (game.strongholdCards) assert.ok(Object.values(game.strongholdCards.owners).every(owner => owner === null));
},
);
void test('independent Discovery capability requires explicit cards, Discovery, Nexus and registered selected seats', () => {
const base = { advanced: true, expansions: [], players: [{ faction: 'atreides' }, { faction: 'guild' }],
ecazTreachery: true, discoveryEnabled: true, nexusCards: true };
assert.equal(independentDiscoveryNexusProfile(base), true);
const invalid = [
{ ...base, ecazTreachery: false }, { ...base, discoveryEnabled: false },
{ ...base, discoveryEnabled: undefined, discoveries: {} }, { ...base, nexusCards: false },
{ ...base, expansions: ['ix', 'ix'] }, { ...base, expansions: ['ecaz', 'ix', 'choam', 'ecaz'] },
{ ...base, expansions: ['sandtrout'] }, { ...base, players: [{ faction: 'atreides' }] },
{ ...base, players: [{ faction: 'atreides' }, { faction: 'atreides' }] },
{ ...base, players: [{ faction: 'atreides' }, { faction: 'richese' }] },
{ ...base, players: [{ faction: 'atreides' }, { faction: 'unknown' }] },
{ ...base, expansions: ['ix'], players: [{ faction: 'ixians' }, { faction: 'richese' }] },
{ ...base, techTokens: true }, { ...base, advanced: false, strongholdCards: true },
{ ...base, semutaPreview: true }, { ...base, advancedPreview: true },
{ ...base, kullPreview: true }, { ...base, nexusKullPreview: true },
{ ...base, richeseBetrayalPreview: true },
];
for (const configuration of invalid)
assert.equal(independentDiscoveryNexusProfile(configuration), false, JSON.stringify(configuration));
});
void test('live independent Discovery retains native stamps and transient encounters while preserving trained E3 capture boundary', () => {
const native = { advanced: true, expansions: ['ecaz'],
players: [{ faction: 'moritani' }, { faction: 'harkonnen' }],
ecazTreachery: true, discoveryEnabled: true, nexusCards: true };
assert.equal(independentDiscoveryNexusProfile({ ...native, moritaniAssassinatePreview: true }), true);
assert.equal(independentDiscoveryNexusProfile({ ...native, leaderSkills: true }), false);
assert.equal(independentDiscoveryNexusProfile({ ...native, advanced: false, leaderSkills: true }), true);
assert.equal(independentDiscoveryNexusProfile({ ...native, advanced: false, moritaniAssassinatePreview: true }), false);
assert.equal(independentDiscoveryNexusProfile({ ...native, leaderSkills: true,
players: [{ faction: 'atreides' }, { faction: 'harkonnen' }] }), true,
'Selecting an unused E3 deck does not add native trained capture.');
const encounter = { ...native, expansions: ['ecaz', 'ix'],
players: [{ faction: 'moritani' }, { faction: 'ixians' }, { faction: 'guild' }],
nexusIxianBetrayalPreview: true, moritaniAssassinatePreview: true,
discoveries: {}, discoveryStash: {}, greatMaker: {}, sandtrout: {},
homeworlds: true, leaderSkills: true, techTokens: true, strongholdCards: true };
assert.equal(independentDiscoveryNexusProfile(encounter), true,
'The public runtime envelope does not reject a legal initialized module because an encounter exists.');
assert.equal(nexusCoreProfile(encounter), true);
assert.equal(independentNexusProfile(encounter), true);
assert.equal(independentDiscoveryNexusProfile({ ...encounter,
players: [{ faction: 'moritani' }, { faction: 'tleilaxu' }, { faction: 'guild' }] }), false,
'An Ixian-specific native stamp requires the actual seated Ixian.');
});