Compose classic independent Treachery and Nexus cards
This commit is contained in:
1 parent
e70cd48172
commit
c466107452
21 files changed
+733
-21
No files matched your search
@@ -29,6 +29,28 @@ optional skill bands and combinations remain open.
|
||||
Rules implementation continues; comprehensive assurance and administration
|
||||
remain deferred until all rules are implemented.
|
||||
|
||||
**Classic independent Nexus — 18 October. Prototyped / Partial.**
|
||||
Original classic3 normal start rejected unchanged. One public configuration
|
||||
capability now composes fresh classic2–6 Basic/Advanced independent36/Nexus12,
|
||||
optionally all14, through original setup and effect consumers. Native selected
|
||||
families, World/phase/Discovery/Semuta and extra reaction-preview envelopes stay
|
||||
separate. No played game is reflagged.
|
||||
|
||||
Actual original Recruits/fixed paid Tleilaxu5/free2, selected bank purchase and
|
||||
native Cunning with separate Reinforcements reserves/physical Harass returns
|
||||
pass, including cancellation and Basic power rejection. Three390px surfaces
|
||||
cover normal all14 lobby/setup, full Revival into Shipment, and native paid
|
||||
battle into Collection; no errors/overflow. Four legal all14 policies execute
|
||||
575 actions/12 battles, not calibration or full-combination acceptance.
|
||||
|
||||
Frozen25-file **249/249**, types, final lint and build pass. A stale paired
|
||||
Reinforcements helper now answers the actual Moritani choice before disposal;
|
||||
its obsolete log-wording pin is deleted. Initial fixture-name lint is fixed.
|
||||
Temporary routes/memory removed/released204; dedicated normal QA room preserved.
|
||||
No reset/restart/deployment. Other effect bounds, complete modes and deferred
|
||||
assurance remain open.
|
||||
[Source, original quantities and exact proof](NEXUS_CARD_RULES.md#classic-independent-treachery-composition--18-october-2026).
|
||||
|
||||
**Paid Black Market retention — 18 October. Prototyped / Partial.**
|
||||
Under the recorded choose-recommended policy, a positive seller win retains the
|
||||
same card/order, pays native Emperor/Bank income and counts one sale. Zero/unbid
|
||||
|
||||
@@ -2,6 +2,26 @@
|
||||
|
||||
Audit: 2026-09-06. Scope: Reinforcements, Recruits, and Harass & Withdraw. This document records component evidence and implementation boundaries; it does not certify an implemented expansion. No runtime files were changed.
|
||||
|
||||
## Classic independent Nexus composition — 18 October 2026
|
||||
|
||||
The original independent-variant authority below now also connects fresh
|
||||
classic2–6 Basic/Advanced independent36/Nexus12, optionally all14 Skills,
|
||||
without other overlays. One public configuration predicate feeds the original
|
||||
setup and native effect consumers. Original lottery/deal/training order,
|
||||
physical twelve-card Nexus inventory and actual alliance/draw are retained;
|
||||
no played hands, deaths, forces or phase clocks are staged.
|
||||
|
||||
Real Recruits plus fixed paid Tleilaxu5/free ordinary2 and native Emperor
|
||||
Cunning/Reinforcements/Harass retain original prices, ordinary limits, reserve
|
||||
versus board custody and cancellation. This is configuration composition,
|
||||
not a new numerical ruling; inherited provisional fee/reveal timings remain
|
||||
labelled in their original contracts. Native selected families, Worlds,
|
||||
phase modules, Discovery/Semuta and reaction-preview envelopes stay separate.
|
||||
Frozen25-file249/249, types/lint/build, three actual390px surfaces and four
|
||||
legal all14 policies pass. This does not certify every Nexus effect, complete
|
||||
modes, saved/deployed assurance or strategy.
|
||||
[Source, cases and remaining boundaries](NEXUS_CARD_RULES.md#classic-independent-treachery-composition--18-october-2026).
|
||||
|
||||
## Sources and reproducible visual anchors
|
||||
|
||||
- **E3:** [GF9 Ecaz & Moritani rulebook](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf), printed pp. 4, 9, 11, 16. Publisher-indexed text was retrieved. Printed pp. 4 and 11 were also visually inspected in the [publisher-authored PDF mirror](https://gamers-hq.de/media/pdf/0f/7a/86/Dune_EcazMoritani_Rulebook_EN.pdf). The mirror is not assumed to resolve revision conflicts elsewhere in this expansion.
|
||||
|
||||
@@ -1,5 +1,37 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 18 October 2026 — Classic independent Treachery and Nexus
|
||||
|
||||
Original normal classic3 independent+Nexus start rejected unchanged. One public
|
||||
configuration predicate now composes fresh classic2–6 Basic/Advanced36/12,
|
||||
optionally all14; original setup/deal/training and effect consumers remain.
|
||||
No selected expansion families, World/phase/Discovery/Semuta or extra reaction
|
||||
preview is admitted by this entry. Unsupported normal starts still retain flags
|
||||
and reject unchanged. No played game is reflagged.
|
||||
|
||||
Actual original Recruits/fixed Tleilaxu5/free2 revival, selected bank-card
|
||||
purchase and native Emperor Cunning with three-reserve Reinforcements fees
|
||||
and physical Harass returns are connected. Native cancellation and Basic
|
||||
power rejection retain card spending/support/loss behavior. The producer
|
||||
orders unused original lotteries before original all14 starting deals and
|
||||
reserves requested future slots; it stages no played hand, force or death.
|
||||
|
||||
Frozen25-file **249/249**, types, final lint and build pass. Initial248/249
|
||||
exposed a legacy paired Reinforcements helper stopping at a real Moritani
|
||||
assassination choice; it now submits the original decline before custody
|
||||
checks. The obsolete exact log-wording pin is removed. Initial lint found
|
||||
a reserved fixture variable name; renaming it clears the actual lint error.
|
||||
|
||||
Three actual390px surfaces pass: normal lobby through all14 training/Storm,
|
||||
Recruits paid5/free leader plus ordinary free2 into Shipment, and paid native
|
||||
Cunning/Reinforcements/Harass through original inspection into Collection.
|
||||
No errors/overflow. Four legal all14 policies perform575 actions/12 battles;
|
||||
this is not calibration or full-combination proof. Temporary routes/memory
|
||||
are removed/released204 and the dedicated normal QA room is preserved.
|
||||
No reset/restart/deployment; existing build warnings remain. Other effect
|
||||
bounds, full modes and deferred assurance remain open.
|
||||
[Canonical source and quantities](NEXUS_CARD_RULES.md#classic-independent-treachery-composition--18-october-2026).
|
||||
|
||||
## 18 October 2026 — Provisional paid Black Market retention
|
||||
|
||||
Original funded seller bid1 rejected unchanged. Under standing choose-recommended
|
||||
|
||||
@@ -7,6 +7,24 @@ and fixture opt-ins are removed; existing games are not retrofitted.
|
||||
Earlier dated references to a separate entry describe historical checkpoints.
|
||||
See [current activation and limits](SPICE_BANKER_RUNTIME.md#mandatory-ordinary-skills-default--17-october-2026).
|
||||
|
||||
## Classic independent Nexus composition — 18 October 2026
|
||||
|
||||
Fresh classic2–6 Basic/Advanced all14 may now select independent36 plus
|
||||
original Nexus12 without any other overlay. One public configuration
|
||||
predicate joins the original initializers and effect quotes; native offers,
|
||||
starting-card-before-training order and mandatory normal Banker remain.
|
||||
An unused expansion selection is not part of this entry. Native selected
|
||||
families, World/phase/Discovery/Semuta and reaction-preview envelopes retain
|
||||
their existing separate limits; no played save is converted.
|
||||
|
||||
Actual normal module selection completes original training and first Storm.
|
||||
Real Nexus draws then exercise Recruits/fixed Tleilaxu revival, a selected
|
||||
ordinary bank purchase and native Cunning with separate Reinforcements fees
|
||||
and Harass returns. Frozen25-file249/249, types/lint/build, three390px
|
||||
surfaces and four legal all14 policies pass. Broader effect bounds, full
|
||||
combinations, strategy, deferred assurance and deployed acceptance stay open.
|
||||
[Exact source, native quantities and proof](NEXUS_CARD_RULES.md#classic-independent-treachery-composition--18-october-2026).
|
||||
|
||||
## Provisional paid Richese retention — 18 October 2026
|
||||
|
||||
Positive Black Market seller wins now retain the same held card/order and pay
|
||||
|
||||
@@ -2,6 +2,57 @@
|
||||
|
||||
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.
|
||||
|
||||
## Classic independent Treachery composition — 18 October 2026
|
||||
|
||||
**Prototyped / Partial.** Publisher E3 pp4/9/11 permits the three-card
|
||||
variant and original twelve Nexus Cards independently of other variants and
|
||||
faction selection. Fresh classic two-through-six-seat Basic/Advanced lobbies
|
||||
now compose independent36/Nexus12, optionally all14 Skills. No new numeric
|
||||
rule is introduced. Native selected families, Worlds, Tech/Strongholds,
|
||||
Discoveries, Semuta and extra reaction-preview envelopes remain outside this
|
||||
entry; original no-independent-card entries retain their own bounds.
|
||||
|
||||
`classicIndependentNexusProfile` is the shared public configuration predicate.
|
||||
Original initializers still own physical inventories, skill offers, starting
|
||||
hands and setup order. All14 starts deal their original starting cards before
|
||||
training completes. The shared producer orders only unused original lottery
|
||||
inputs, reserving all requested future slots; it never replaces a played hand,
|
||||
reflags a played game or assigns a held Nexus face. A real turn-two worm,
|
||||
reciprocal alliance and unallied closing draw supply the exercised cards.
|
||||
|
||||
- Native Recruits doubles the ordinary rate and raises its total ceiling to7.
|
||||
Tleilaxu Secret Ally remains a separate one-spice-per-force fixed group:
|
||||
paid5 then ordinary free2 returns7, revives the actual dead Yueh once and
|
||||
leaves one of the original eight dead forces in Tanks. Ordinary5 before
|
||||
Nexus2 also retains its already-paid price; no refund or extra group.
|
||||
- Absent-Emperor Secret Ally admits the selected independent ordinary lot at
|
||||
its original fully affordable bank invoice, paying or consuming the one
|
||||
Nexus Card once and delivering the original physical Treachery Card once.
|
||||
- Advanced Emperor Cunning still converts five existing ordinary counters
|
||||
temporarily. A real paid shipment supplies the army; dial4/support2 plus
|
||||
Reinforcements consumes three separate reserves, loses two actual board
|
||||
counters, and Harass returns the opponent's nine undialed counters. Original
|
||||
Karama cancellation restores normal support/losses without refunding Nexus.
|
||||
Basic retains its original Advanced-power rejection and unspent card.
|
||||
|
||||
New consumer regressions:
|
||||
`tests/classic-independent-nexus-utility.test.ts` and
|
||||
`tests/classic-independent-nexus-battle.test.ts`, with the shared
|
||||
`tests/fixture-classic-independent-nexus.ts` and their named utility/battle
|
||||
producers. Frozen25-file **249/249**, types, lint and build pass. The original
|
||||
paired Reinforcements helper now answers its actual pending Moritani choice
|
||||
before checking disposal; an obsolete log-wording pin is removed.
|
||||
|
||||
Three actual390px surfaces pass: normal lobby→original all14 setup/Storm,
|
||||
Recruits→fixed paid5/free leader→ordinary free2→Shipment, and paid native
|
||||
Cunning/Reinforcements/Harass→Traitors→original inspection→Collection.
|
||||
No errors/overflow. Four all14 legal policies execute575 actions and12 actual
|
||||
battles; these are playability smokes, not strength calibration or full games
|
||||
for every combination. Temporary memory/routes are removed and released204;
|
||||
the dedicated normal QA room is preserved. No reset/restart/deployment.
|
||||
Other effect-specific face/custody bounds and complete-module, comprehensive
|
||||
assurance and deployed acceptance remain open.
|
||||
|
||||
## Moritani permanent markers and live replacements — 15 October 2026
|
||||
|
||||
Original Harkonnen Secret Ally now works after Moritani has permanently retired
|
||||
|
||||
@@ -702,6 +702,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) |
|
||||
| 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) |
|
||||
| Fresh native Ecaz/Moritani defaults | Fresh actual Eca/Mori roles reuse registered selected-deck all14 capability, including mixed families and original World/phase/cards without Nexus. Printed Eca participation and Advanced Mori assassination initialize only for actual seats; Basic gains no Advanced power and unused decks create no faction seat. Bare rosters and original Nexus/Discovery/Semuta retain their envelopes; Advanced role/shared Duke guards stay. No played-save forfeiture/opportunity history is fabricated. | [Current registered source envelope](LEADER_SKILLS_RUNTIME.md#registered-native-e3-selected-decks--18-october-2026), [Mori authority](MORITANI_ASSASSINATE_LEADERS.md#connected-scope-and-private-information) |
|
||||
| Independent Treachery / original phase modules | Printed independence retains original Tech3–6 Basic/Advanced and/or Strongholds2–6 Advanced with classic/E1/E2 and original standalone/paired E3 bare cards; all14 also composes registered mixed selected families and Worlds. Real first-Storm/end-Mentat owners, separate delayed income and physical funding remain. Fees/H are not revival/shipment and +2 is not supported force. Native coalition/assassination and original queue stay source-labelled. Bare mixed E3 World/phase, original Nexus/Discovery/Semuta and role/effect guards remain. | [Original phase contract](ECAZ_TREACHERY_RULES.md#original-phase-module-composition--16-october-2026), [registered scope](LEADER_SKILLS_RUNTIME.md#registered-native-e3-selected-decks--18-october-2026) |
|
||||
| Classic World independent cards / phase modules | Compose original unused Tech3+ and/or Advanced Strongholds2+ with the existing classic36-card Homeworld entry. Preserve native typed custody, current separate-request rates/paid ledgers, exact source-bearing R fees, original face/support, fee/dial disjointness and fee-before-Harass. Keep every own-World Harass ban; native returns are not shipment/free Revival. Original delayed income and earned cards add no personal subsidy or supported +2. This inherits existing provisional timing, not a new publisher ruling or native expansion/public gate. | [Source-bound contract](ECAZ_TREACHERY_RULES.md#classic-world-phase-module-composition--16-october-2026) |
|
||||
|
||||
+4
-4
@@ -1,5 +1,5 @@
|
||||
import { classicNexusModulesProfile, mixedE1E2NexusProfile, mixedE3NexusProfile, pairedE3NexusProfile, pairedNexusModulesProfile, singleE1E2NexusProfile, standaloneE3NexusProfile } from './nexus-module-profile';
|
||||
import { nativeFactionTechProfile } from './faction-module-profile';
|
||||
import { classicIndependentNexusProfile, 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';
|
||||
@@ -10736,7 +10736,7 @@ export function initializeLeaderSkillsGameForAudit(state: Game): Game {
|
||||
const homeworlds = homeworldLeaderSkillsProfile(state);
|
||||
const independent = independentTreacheryLeaderSkillsProfile(state);
|
||||
if (state.ecazTreachery) {
|
||||
requireRule(independent, 'Independent cards with Skills require a fresh supported selected-deck roster with original Homeworlds/Tech/Advanced Strongholds; Nexus/Discovery and unrelated preview contexts remain gated.');
|
||||
requireRule(independent, 'Independent Skills cards require supported selected decks with original World/phase modules, or classic Nexus without other overlays; Discovery and unrelated preview contexts remain gated.');
|
||||
requireFreshBaseRuntime(state);
|
||||
requireFreshFactionInventory(state);
|
||||
}
|
||||
@@ -10822,7 +10822,7 @@ export function initializeEcazTreacheryGameForAudit(state: Game): Game {
|
||||
const homeworlds = !!g.homeworlds;
|
||||
requireRule(!homeworlds || ecazHomeworldProfile(g),
|
||||
'Independent cards with Homeworlds need a fresh supported selected-deck roster with original Tech or Advanced Strongholds.');
|
||||
const initialized = initializeSetupGameForAudit(g, homeworlds, false, false, false, false, false, true, true);
|
||||
const initialized = initializeSetupGameForAudit(g, homeworlds, classicIndependentNexusProfile(g), false, false, false, false, true, true);
|
||||
if (byFaction(initialized, 'ecaz')) initialized.ecazOccupyPreview = true;
|
||||
if (moritaniAssassinateModeSupported(initialized)) initializeMoritaniAssassinateState(initialized);
|
||||
return initialized;
|
||||
@@ -13896,7 +13896,7 @@ function recruitsModeSupported(g: Game) {
|
||||
const modules = independentCardModulesProfile(g);
|
||||
return (
|
||||
(!g.homeworlds || ecazHomeworldProfile(g)) &&
|
||||
!g.nexusCards &&
|
||||
(!g.nexusCards || classicIndependentNexusProfile(g)) &&
|
||||
(!g.leaderSkills || independentTreacheryLeaderSkillsProfile(g)) &&
|
||||
(!g.techTokens || modules) &&
|
||||
(!g.strongholdCards || modules) &&
|
||||
|
||||
@@ -26,6 +26,31 @@ export type FactionModuleProfile = {
|
||||
nexusHarkonnenBetrayalPreview?: unknown;
|
||||
};
|
||||
|
||||
/** Independent36 + original Nexus12 in classic tables, optionally all14 Skills.
|
||||
* Initializers own undealt component freshness; other overlays stay separate. */
|
||||
export function classicIndependentNexusProfile(
|
||||
game: Partial<FactionModuleProfile> & Pick<FactionModuleProfile, 'expansions'> & {
|
||||
sandtrout?: unknown; moritaniAssassinatePreview?: unknown;
|
||||
},
|
||||
): boolean {
|
||||
const players = game.players;
|
||||
if (typeof game.advanced !== 'boolean' || game.ecazTreachery !== true || !game.nexusCards ||
|
||||
game.expansions.length !== 0 || !players || players.length < 2 || players.length > 6 ||
|
||||
game.homeworlds || game.techTokens || game.strongholdCards || game.sandtrout ||
|
||||
game.discoveryEnabled || game.discoveries || game.discoveryStash || game.greatMaker ||
|
||||
game.semutaPreview || game.moritaniAssassinatePreview || game.advancedPreview || game.kullPreview || game.nexusKullPreview ||
|
||||
game.guildBetrayalPreview || game.richeseBetrayalPreview || game.nexusIxianReplacementPreview ||
|
||||
game.nexusIxianBetrayalPreview || game.nexusHarkonnenBetrayalPreview) return false;
|
||||
for (let i = 0; i < players.length; i++) {
|
||||
for (let j = 0; j < i; j++) if (players[j].faction === players[i].faction) return false;
|
||||
let classic = false;
|
||||
for (const faction of FACTIONS)
|
||||
if (faction.id === players[i].faction) { classic = faction.expansion === 'base'; break; }
|
||||
if (!classic) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Original E1/E2 families or standalone Ecaz/Moritani; explicit Semuta context only. */
|
||||
export function nativeFactionTechProfile(game: FactionModuleProfile, semuta = false): boolean {
|
||||
if (typeof game.advanced !== 'boolean' || !game.techTokens ||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FACTIONS } from './catalog';
|
||||
import { mixedE3NexusRosterProfile } from './faction-module-profile';
|
||||
import { classicIndependentNexusProfile, 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. */
|
||||
@@ -53,6 +53,7 @@ function nexusLeaderSkillModulesSupported(game: LeaderSkillProfile, assassinatio
|
||||
|
||||
/** Fresh classic Nexus/Skills, optionally Homeworlds/Discovery/Tech/Advanced Strongholds. */
|
||||
export function classicNexusLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
|
||||
if (classicIndependentNexusProfile(game)) return true;
|
||||
if (game.expansions.length || !nexusLeaderSkillModulesSupported(game, false, true, false)) return false;
|
||||
const players = game.players!;
|
||||
for (let i = 0; i < players.length; i++) {
|
||||
@@ -475,6 +476,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 (classicIndependentNexusProfile(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 ||
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { RicheseAuction } from './richese-auction';
|
||||
import { quoteRicheseSettlement, type RicheseSettlementQuote } from './richese-settlement';
|
||||
import { quoteRicheseBetrayalInvoice } from './nexus-richese-betrayal';
|
||||
import { RICHESE_CARD_DEFINITIONS, richeseCardDefinition } from './richese-cards';
|
||||
import { classicIndependentNexusProfile } from './faction-module-profile';
|
||||
|
||||
export type EmperorNexusBankPurchase = {
|
||||
kind: 'purchase';
|
||||
@@ -380,6 +381,8 @@ function nativeBankNexusProfile(g: Game): boolean {
|
||||
* with the Emperor absent, the concealed face and any buyer wallet or hand left
|
||||
* to the caller. */
|
||||
export function emperorNexusBankPurchaseModeSupported(g: Game): boolean {
|
||||
if (classicIndependentNexusProfile(g))
|
||||
return !g.players.some(p => p.faction === 'emperor') && !g.sandtrout;
|
||||
if (!g.nexusCards || g.players.some(p => p.faction === 'emperor') ||
|
||||
g.ecazTreachery || g.sandtrout) return false;
|
||||
return emperorNexusLegacyRevivalModeSupported(g) || nativeBankNexusProfile(g);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Game, Player } from './engine';
|
||||
import { eliteRevivalRemaining, revivalPrevented } from './revival';
|
||||
import { FACTIONS } from './catalog';
|
||||
import { classicIndependentNexusProfile } from './faction-module-profile';
|
||||
import {
|
||||
classicNexusModulesProfile,
|
||||
pairedNexusModulesProfile,
|
||||
@@ -205,6 +206,7 @@ export function validateEmperorNexusRevival(
|
||||
|
||||
/** Reuse original fresh roster/module predicates, independently of purchase admission. */
|
||||
function revivalModeSupported(g: Game): boolean {
|
||||
if (classicIndependentNexusProfile(g)) return true;
|
||||
if (g.semutaPreview || g.advancedPreview || g.kullPreview || g.nexusKullPreview ||
|
||||
g.guildBetrayalPreview || g.richeseBetrayalPreview || g.nexusIxianReplacementPreview ||
|
||||
g.nexusIxianBetrayalPreview || g.nexusHarkonnenBetrayalPreview || g.ecazTreachery)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Game } from './engine';
|
||||
import { nexusCleanPlayBlocked } from './nexus-play-boundary';
|
||||
import { classicIndependentNexusProfile } from './faction-module-profile';
|
||||
|
||||
export type FremenNexusBetrayal = {
|
||||
event: string;
|
||||
@@ -39,10 +40,11 @@ export function fremenBetrayalOffer(
|
||||
if (holder.ally) blocked = 'An allied player cannot use a Nexus card.';
|
||||
else if (g.status !== 'playing' || (g.phase !== 1 && g.phase !== 5))
|
||||
blocked = 'Use Fremen Betrayal before the first Spice Blow or before Fremen movement.';
|
||||
else if (g.expansions.length || g.homeworlds || g.leaderSkills || g.discoveryEnabled ||
|
||||
else if (!classicIndependentNexusProfile(g) &&
|
||||
(g.expansions.length || g.homeworlds || g.leaderSkills || g.discoveryEnabled ||
|
||||
g.discoveries || g.discoveryStash || g.greatMaker || g.ecazTreachery ||
|
||||
g.techTokens || g.strongholdCards ||
|
||||
g.players.some(p => !['atreides','harkonnen','emperor','fremen','guild','beneGesserit'].includes(p.faction)))
|
||||
g.players.some(p => !['atreides','harkonnen','emperor','fremen','guild','beneGesserit'].includes(p.faction))))
|
||||
blocked = 'Combined expansion and module interactions remain unavailable.';
|
||||
else if (nexusCleanPlayBlocked(g, automaticPending))
|
||||
blocked = 'Finish the current interaction before using Fremen Betrayal.';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FACTIONS } from './catalog';
|
||||
import { mixedE3NexusRosterProfile, type FactionModuleProfile } from './faction-module-profile';
|
||||
import { classicIndependentNexusProfile, mixedE3NexusRosterProfile, type FactionModuleProfile } from './faction-module-profile';
|
||||
import { classicDiscoveryNexusProfile, mixedE1E2DiscoveryNexusProfile, mixedE3DiscoveryNexusProfile, pairedDiscoveryNexusProfile, pairedE3DiscoveryNexusProfile, singleE1E2DiscoveryNexusProfile, standaloneE3DiscoveryNexusProfile } from './discovery-module-profile';
|
||||
|
||||
/** Explicit Richese composition only; original predicates still own roster and module legality. */
|
||||
@@ -11,6 +11,7 @@ function semutaNexusContext(game: FactionModuleProfile): boolean {
|
||||
|
||||
/** Original classic Nexus modules, optionally Homeworlds; initializer checks undealt components. */
|
||||
export function classicNexusModulesProfile(game: FactionModuleProfile): boolean {
|
||||
if (classicIndependentNexusProfile(game)) return true;
|
||||
if (game.discoveryEnabled || game.discoveries) return classicDiscoveryNexusProfile(game);
|
||||
if (typeof game.advanced !== 'boolean' || !game.nexusCards ||
|
||||
(!game.techTokens && !game.strongholdCards) || game.expansions.length !== 0 ||
|
||||
|
||||
+5
-2
@@ -4674,6 +4674,8 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
summary: 'A separate twelve-card deck rewards remaining unallied. Private draws and selected Atreides, Harkonnen, Tleilaxu, Ixian, Bene Gesserit, Emperor, Fremen, CHOAM, Moritani, Richese and Guild effects are connected. Other effects and complete module play remain in development.',
|
||||
steps: [
|
||||
'This optional module can be combined with Basic or Advanced rules independently of the factions and other expansion modules selected. Include one Nexus Card for each of the twelve factions.',
|
||||
'Classic two-through-six-seat Basic/Advanced tables may select the independent Recruits, Reinforcements and Harass & Withdraw variant with Nexus, optionally all fourteen Skills. Keep original36 Treachery,12 Nexus and14 Skill cards distinct. Starting hands/skills use their original order; the real closing draw still requires an actual Nexus and alliance. This entry adds no native selected families, Worlds, phase modules, Discoveries, Semuta or extra reaction-preview protocol.',
|
||||
'Recruits changes native Revival rates and the ordinary seven ceiling, not the fixed Tleilaxu Secret Ally group or its one-spice price. Five paid card counters may leave two ordinary free returns, using the one leader slot once. Emperor Cunning still makes five ordinary board counters temporarily Sardaukar: independent reserve fees use separate physical reserves, Harass returns actual undialed counters, and native Karama changes only the original Nexus effect. Native winner/inspection and Collection continue without extra cleanup prompts.',
|
||||
'At the end of the entire Spice Blow and Nexus phase, if a Nexus occurred and at least one alliance exists, each unallied player may draw one card or discard their held card and draw a replacement. You may decline. The two Advanced spice piles do not grant two closing draws.',
|
||||
'Keep your card secret until it is used. You may hold at most one Nexus Card. It is separate from your Treachery hand and does not occupy a Treachery hand slot.',
|
||||
'Whenever you draw your own faction card, you may immediately discard it and draw again, in either Basic or Advanced play. A replacement is discarded before the new draw; when the deck is empty, shuffle its discard pile to refill it.',
|
||||
@@ -4746,7 +4748,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{area:"Player controls",status:'Partial',detail:"Four current 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 original policies 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:"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:"Current thirteen-file92/92 passes, including7 new meaningful cases. Eleven original-action runtime/policy paths and four actual original-action390px journeys pass. Original setup, paid Karama, actual Ecaz/Moritani alliance and real Guild Richese draw retain conserved undealt entropy, without played-state staging. Earlier131-case Junction and263-case source checks remain historical. No saved/deployed or complete-module assurance.",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/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-skills-modules-runtime.test.ts', 'docs/NEXUS_CARD_RULES.md', 'tests/nexus-tleilaxu-revival.test.ts', 'tests/fixture-nexus-tleilaxu-secret-ally.ts', 'tests/nexus-tleilaxu-secret-ally-runtime.test.ts', 'tests/nexus-borrowed-voice.test.ts', 'tests/fixture-nexus-borrowed-voice.ts', 'tests/nexus-borrowed-voice-runtime.test.ts', 'tests/nexus-fremen-protection.test.ts', 'tests/fixture-nexus-fremen-protection.ts', 'tests/nexus-fremen-protection-runtime.test.ts', 'tests/fixture-stronghold-factions.ts', 'tests/fixture-nexus-advisor-fresh.ts', 'tests/nexus-advisor-fresh-runtime.test.ts', 'tests/fixture-nexus-advisor-storm.ts', 'tests/nexus-advisor-storm-runtime.test.ts', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'tests/nexus-fremen-free-quota.test.ts', 'tests/fixture-nexus-fremen-free-quota.ts', 'tests/nexus-fremen-free-quota-runtime.test.ts', 'tests/nexus-fremen-revival.test.ts','tests/nexus-emperor-payment.test.ts','tests/fixture-nexus-emperor-payment.ts','tests/nexus-emperor-payment-runtime.test.ts','docs/NEXUS_EMPEROR_RULES.md', 'tests/nexus-emperor-extra-source.test.ts', 'tests/nexus-emperor-extra-continuation.test.ts', 'tests/fixture-nexus-emperor-extra.ts', 'tests/nexus-emperor-extra-runtime.test.ts', 'tests/fixture-single-nexus-e1.ts', 'docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md', "tests/fixture-nexus-emperor-seller.ts", "tests/nexus-emperor-seller-purchase.test.ts", "tests/nexus-emperor-seller-runtime.test.ts", "tests/fixture-nexus-emperor-ambassador.ts", "tests/nexus-emperor-ambassador-purchase.test.ts", "tests/nexus-emperor-ambassador-runtime.test.ts", "tests/fixture-nexus-emperor-special.ts", "tests/nexus-emperor-special-purchase.test.ts", "tests/nexus-emperor-special-runtime.test.ts", "tests/karama-intents.test.ts", "tests/fixture-nexus-eLine truncated
|
||||
{area: "Verification", status: 'Partial', detail: "Classic independent36/Nexus12 with optional14 uses original normal setup, real closing draws, native Recruits plus fixed Tleilaxu grant, and Cunning with separate reserve fees and undialed returns. Selected bank-card purchase and native cancellation remain. Three actual390px surfaces and four legal all14 policies pass; older native/module and saved evidence stays dated. Full Nexus, added overlays, comprehensive assurance and deployed acceptance remain open.", evidence: ['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-skills-modules-runtime.test.ts', 'docs/NEXUS_CARD_RULES.md', 'tests/nexus-tleilaxu-revival.test.ts', 'tests/fixture-nexus-tleilaxu-secret-ally.ts', 'tests/nexus-tleilaxu-secret-ally-runtime.test.ts', 'tests/nexus-borrowed-voice.test.ts', 'tests/fixture-nexus-borrowed-voice.ts', 'tests/nexus-borrowed-voice-runtime.test.ts', 'tests/nexus-fremen-protection.test.ts', 'tests/fixture-nexus-fremen-protection.ts', 'tests/nexus-fremen-protection-runtime.test.ts', 'tests/fixture-stronghold-factions.ts', 'tests/fixture-nexus-advisor-fresh.ts', 'tests/nexus-advisor-fresh-runtime.test.ts', 'tests/fixture-nexus-advisor-storm.ts', 'tests/nexus-advisor-storm-runtime.test.ts', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'tests/nexus-fremen-free-quota.test.ts', 'tests/fixture-nexus-fremen-free-quota.ts', 'tests/nexus-fremen-free-quota-runtime.test.ts', 'tests/nexus-fremen-revival.test.ts','tests/nexus-emperor-payment.test.ts','tests/fixture-nexus-emperor-payment.ts','tests/nexus-emperor-payment-runtime.test.ts','docs/NEXUS_EMPEROR_RULES.md', 'tests/nexus-emperor-extra-source.test.ts', 'tests/nexus-emperor-extra-continuation.test.ts', 'tests/fixture-nexus-emperor-extra.ts', 'tests/nexus-emperor-extra-runtime.test.ts', 'tests/fixture-single-nexus-e1.ts', 'docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md', "tests/fixture-nexus-emperor-seller.ts", "tests/nexus-emperor-seller-purchase.test.ts", "tests/nexus-emperor-seller-runtime.test.ts", "tests/fixture-nexus-emperor-ambassador.ts", "tests/nexus-emperor-ambassador-purchase.test.ts", "tests/nexus-emperor-ambassador-runtime.test.ts", "tests/fLine truncated
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -4932,6 +4934,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
summary: 'Fourteen physical skills, private assignment choices, five battle disciplines, Planetologist movement/battle effects and Suk Graduate rescues, Mentat/Bureaucrat scoring and Bureaucrat payment redirection, Sandmaster victory spice, Rihani exchanges, optional Smuggler shipping and provisional modifier-inclusive battle collection, Sandmaster route and worm-ride collection, sealed Spice Banker battle spending and optional Diplomat base-defense copying in development tables. Other effects and combined-module acceptance remain unfinished.',
|
||||
steps: [
|
||||
'Fresh all14 uses one selected-deck capability for every registered actual seat, Basic/Advanced, including mixed native Ecaz/Moritani with selected E1/E2 families. Neither an expansion faction, independent cards, Nexus nor one exact E3 family is required just to use selected ordinary decks. An unused deck creates no seated faction. Original World/phase envelopes retain components and native power bindings; original Nexus/Discovery/Semuta and Advanced role limits stay separate. Ix drafts before training; Auditor/shared Duke never train. No Advanced power, income, owner or played-save history is invented.',
|
||||
'The separate classic independent36/Nexus12 entry also permits optional all14 in Basic/Advanced, two through six base-faction seats, without other overlays. Actual offers, starting-card order, mandatory normal Banker and original Nexus ability sources remain. New ordinary-card recognition is selected by configuration, never by a hidden hand or Nexus face. Native expansion, World/phase/Discovery/Semuta and extra reaction-preview card contexts retain their separate bounds.',
|
||||
'Explicit guild-betrayal --leader-skills retains the original classic native-Guild Nexus profile without Homeworlds, Tech, Strongholds, Discovery, Semuta or other overlays. All14 training remains native. Ordinary skill quotes now admit the composition, restoring actual Mentat private questions before posture and Sandmaster ground collection rather than only enabling Bureaucrat. Native card custody, paid forces, original Betrayal Use/Pass and movement/plan continuation stay unchanged. Other combinations and complete-mode acceptance remain separate.',
|
||||
'Basic Moritani keeps legal Terror but gains no Advanced assassination, including registered mixed native Skills seats. Ecaz/Moritani with selected E1/E2 or unused decks keeps original Worlds/Tech3+/Advanced Strongholds2+ and optional independent cards without requiring Nexus. Advanced E3/Harkonnen remains guarded. Winner Suk and Rihani precede a different unused trained-disc death, one skill return, printed bounty and one original Mentat replacement. Native card/force sources, normal-call forfeiture and shared-Duke exclusion remain; unrelated overlays retain their original envelopes.',
|
||||
'Basic and Advanced selected-deck Skills admits registered base, Ixian/Tleilaxu, CHOAM/Richese and Ecaz/Moritani seats only when their family is selected. Unused decks neither create a seat nor require one. All14/native setup remain. Auditor is Advanced and unskilled; foreign-ghola authorization still explicitly requires Advanced, grants no new skill and retains original Duke/custody limits. Cyborg losses, native funding and capped markers stay physical. Supplied Advanced p34 excludes Richese auctions from Ixian Technology; existing capture/KH/copy/role guards keep their own rules.',
|
||||
@@ -4973,7 +4976,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{ area:'Player controls',status:'Partial',detail:'Private card/leader selection, readable inspectors, public battle posture and revival draw/decline, Planetologist range/gather controls, Special weapon selection and Suk Graduate physical rescue choices are connected. Rihani has private history and separate keep-new/reveal-old controls; automatic battle effects need no confirmation. Smuggler has explicit ordinary shipping opt-out and No-Field companion opt-in with physical-versus-priced force counts, plus surviving-leader battle collection with reveal-time guidance and owned multi-pile allocations. Banker exposes separate sealed spending and authorized inspection. Sandmaster exposes explicit legal routes, per-territory collection choices, optional worm/physical Ambassador destination checkboxes and direct victory-pile placement when several piles remain. Diplomat offers a named committed Worthless choice or decline after public reveal, and a separate surviving-loser destination/normal/elite retreat or decline. Bureaucrat offers redirect or full payment with explicit recipient amounts and deferred bribe custody. Remaining combined-module and exceptional-timing controls are incomplete.',evidence:['components/leader-skills.tsx','components/planetologist-movement.tsx','components/suk-graduate.tsx','components/rihani-decipherer.tsx','components/mentat-question.tsx','components/bureaucrat-payment.tsx','components/leader-skill-battle-guide.tsx','components/smuggler-shipment.tsx','components/smuggler-battle-collection.tsx','components/spice-banker.tsx','components/sandmaster-movement.tsx','components/sandmaster-worm.tsx','components/sandmaster-victory-spice.tsx','components/ambassador-movement.tsx','components/diplomat-defense.tsx','components/diplomat-retreat.tsx','components/game-table.tsx'] },
|
||||
{ area:'AI',status:'Partial',detail:'All four profiles use private offers, legal visibility/revival actions and Planetologist movement/battle candidates and legal Suk Graduate rescues, Rihani exchanges, shared battle scoring, Smuggler reserve/owned No-Field shipments and modifier-inclusive collection plans, exact multi-pile Smuggler allocations, Sandmaster route, worm, physical Ambassador destination collection and victory-pile placement, funded Banker commitments and optional Diplomat copying from public revealed defenses or choosing legal losing-battle retreat. Bureaucrat uses the public payee alliance to redirect enemy income or preserve allied income. These are legal prototype paths; complete skill strategy and difficulty calibration remain unfinished.',evidence:['game/bots.ts'] },
|
||||
{ area:'Documentation',status:'Partial',detail:'All fourteen faces and common rules are recorded. Skilled capture follows public physical-card custody. The 13 October 2026 ruling resolves the captured-card replacement question: the original assignment is retained and no replacement is drawn while it exists; other material timing interpretations remain explicit.',evidence:['docs/LEADER_SKILLS_RULES.md','docs/LEADER_SKILLS_CAPTURE.md','docs/LEADER_SKILLS_RUNTIME.md','docs/PLANETOLOGIST_RULES.md','docs/SUK_GRADUATE_RULES.md','docs/LEADER_BATTLE_EFFECTS.md','docs/MENTAT_QUESTION.md','docs/BUREAUCRAT_PAYMENTS.md','docs/SMUGGLER_SHIPMENT.md','docs/SPICE_BANKER_RUNTIME.md','docs/SANDMASTER_WORM.md','docs/DIPLOMAT_DEFENSE.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Earlier native, Smuggler, optional-module, Diplomat and recovery evidence remains dated in linked contracts. Registered native allied receipts share the ordinary capability. Advanced Richese portions add actual separate recipients, stale cross-leg events, native cancellation, original Banker custody and Basic/no-recipient controls. Frozen18-file413/413, two390px native control flows and four legal policies pass. Original timing/context interpretations, full modes, strategy, deferred assurance and deployed acceptance remain unfinished.', evidence: ['tests/bureaucrat-richese-cache.test.ts','tests/bureaucrat-richese-black-market.test.ts','tests/fixture-bureaucrat-richese-funding.ts','docs/BUREAUCRAT_PAYMENTS.md','tests/diplomat-used-defense.test.ts', 'tests/diplomat-late-journey.test.ts', 'tests/fixture-diplomat-late-journey.ts', 'tests/diplomat-context-journey.test.ts', 'tests/fixture-diplomat-context-journey.ts', 'docs/DIPLOMAT_DEFENSE.md', 'tests/smuggler-kwisatz-journey.test.ts', 'tests/fixture-smuggler-kwisatz-journey.ts', 'tests/smuggler-artillery-journey.test.ts', 'tests/fixture-smuggler-artillery-journey.ts', 'docs/SMUGGLER_BATTLE.md', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts','tests/homeworld-native-e1e2-modules-runtime.test.ts','tests/homeworld-native-e3-modules-runtime.test.ts','tests/homeworld-classic-skills-runtime.test.ts','tests/homeworld-skills-discovery-runtime.test.ts','tests/homeworld-optional-modules-runtime.test.ts','tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/captured-skill-revival.test.ts','tests/fixture-captured-skill-revival.ts','tests/captured-skill-special-revival.test.ts','tests/fixture-captured-skill-special-revival.ts','tests/leader-battle-effects.test.ts','tests/suk-graduate.test.ts','tests/mentat-question.test.ts','tests/bureaucrat-payment.test.ts','tests/smuggler-battle.test.ts','tests/spice-banker.test.ts','tests/advanced-native-skills.test.ts','tests/fixture-advanced-native-skills.ts','tests/skills-tech-payments-runtime.test.ts','tests/skills-tech-battle-runtime.test.ts','tests/ix-choam-skills-tech-runtime.test.ts','tests/tleilaxu-skills-tech-runtime.test.ts','tests/richese-skills-tech-runtime.test.ts','tests/classic-skills-stronghold-runtime.test.ts','tests/native-skills-stronghold-runtime.test.ts','tests/ecaz-skills-modules-runtime.test.ts','tests/moritani-skills-modules-runtime.test.ts','docs/LEADER_SKILLS_RUNTIME.md','docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md'] },
|
||||
{area: 'Verification', status: 'Partial', detail: "Earlier native, Richese, Smuggler, optional-module, Diplomat and saved evidence remains dated in linked contracts. Classic independent36/Nexus12 optional14 now retains original setup and native effect consumers. Actual normal module selection, Recruits fixed-grant revival and Cunning reserve-versus-board battle controls pass; four all14 legal policies retain stock and complete bounded progression. Full modes, strategy, deferred assurance and deployed acceptance remain unfinished.", evidence: ['tests/classic-independent-nexus-utility.test.ts', 'tests/classic-independent-nexus-battle.test.ts', 'tests/fixture-classic-independent-nexus.ts', 'tests/bureaucrat-richese-cache.test.ts','tests/bureaucrat-richese-black-market.test.ts','tests/fixture-bureaucrat-richese-funding.ts','docs/BUREAUCRAT_PAYMENTS.md','tests/diplomat-used-defense.test.ts', 'tests/diplomat-late-journey.test.ts', 'tests/fixture-diplomat-late-journey.ts', 'tests/diplomat-context-journey.test.ts', 'tests/fixture-diplomat-context-journey.ts', 'docs/DIPLOMAT_DEFENSE.md', 'tests/smuggler-kwisatz-journey.test.ts', 'tests/fixture-smuggler-kwisatz-journey.ts', 'tests/smuggler-artillery-journey.test.ts', 'tests/fixture-smuggler-artillery-journey.ts', 'docs/SMUGGLER_BATTLE.md', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts','tests/homeworld-native-e1e2-modules-runtime.test.ts','tests/homeworld-native-e3-modules-runtime.test.ts','tests/homeworld-classic-skills-runtime.test.ts','tests/homeworld-skills-discovery-runtime.test.ts','tests/homeworld-optional-modules-runtime.test.ts','tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/captured-skill-revival.test.ts','tests/fixture-captured-skill-revival.ts','tests/captured-skill-special-revival.test.ts','tests/fixture-captured-skill-special-revival.ts','tests/leader-battle-effects.test.ts','tests/suk-graduate.test.ts','tests/mentat-question.test.ts','tests/bureaucrat-payment.test.ts','tests/smuggler-battle.test.ts','tests/spice-banker.test.ts','tests/advanced-native-skills.test.ts','tests/fixture-advanced-native-skills.ts','tests/skills-tech-payments-runtime.test.ts','tests/skills-tech-battle-runtime.test.ts','tests/ix-choam-skills-tech-runtime.test.ts','tests/tleilaxu-skills-tech-runtime.test.ts','tests/richese-skills-tech-runtime.test.ts','tests/classic-skills-stronghold-runtime.test.ts','tests/native-skills-stronghold-runtime.test.ts','tests/ecaz-skills-modules-runtime.test.ts','tests/moritani-skills-modules-runtime.test.ts','docs/LEADER_SKILLS_RUNTIME.md','docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md']},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -121,15 +121,13 @@ void test('only the host can select a boolean rules mode, changes clear human re
|
||||
completedSetup(act(ready(game), game.host, { type: 'start' }));
|
||||
});
|
||||
|
||||
void test('incompatible selected modules reject the whole start instead of silently omitting independent cards', () => {
|
||||
for (const option of ['nexusCards', 'discoveries']) {
|
||||
let game = act(lobby(['atreides', 'emperor']), 'atreides',
|
||||
{ type: 'moduleOption', module: 'ecazTreachery', enabled: true });
|
||||
game = act(game, game.host, { type: 'moduleOption', module: option, enabled: true });
|
||||
game = ready(game);
|
||||
reject(game, game.host, { type: 'start' });
|
||||
assert.ok(game.players.every(p => !p.hand.length && !p.traitors.length));
|
||||
}
|
||||
void test('unsupported independent cards and Discovery reject the whole start without silently dropping a module', () => {
|
||||
let game = act(lobby(['atreides', 'emperor']), 'atreides',
|
||||
{ type: 'moduleOption', module: 'ecazTreachery', enabled: true });
|
||||
game = act(game, game.host, { type: 'moduleOption', module: 'discoveries', enabled: true });
|
||||
game = ready(game);
|
||||
reject(game, game.host, { type: 'start' });
|
||||
assert.ok(game.players.every(p => !p.hand.length && !p.traitors.length));
|
||||
});
|
||||
|
||||
void test('Advanced starts require the exact host opt-in and cannot dispatch audit initializers or hidden bypass options', () => {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { maxCombatDial } from '../game/combat';
|
||||
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
|
||||
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
|
||||
import {
|
||||
createClassicIndependentNexusBattleFixture,
|
||||
revealIndependentNexusBattle, finishIndependentNexusBattle,
|
||||
independentNexusBattlePlayer as player,
|
||||
} from './fixture-classic-independent-nexus-battle';
|
||||
|
||||
function rejected(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before, 'A rejected native plan or stale Nexus use cannot change physical custody');
|
||||
}
|
||||
|
||||
void test('native Advanced Emperor Cunning changes five shipped ordinary counters, while independent reserve fees and enemy withdrawal retain their physical sources', () => {
|
||||
const f = createClassicIndependentNexusBattleFixture();
|
||||
const before = player(f.game, f.owner), enemy = player(f.game, f.opponent);
|
||||
assert.equal(player(f.afterShipment, f.owner).reserves, player(f.beforeShipment, f.owner).reserves - 5);
|
||||
assert.equal(player(f.afterShipment, f.owner).spice, player(f.beforeShipment, f.owner).spice - 5);
|
||||
assert.equal(f.afterDraw.nexusCards!.cards!.hands[f.owner], 'emperor');
|
||||
const revealed = revealIndependentNexusBattle(f, { cunning: true });
|
||||
assert.equal(revealed.nexusCards!.cards!.hands[f.owner], null);
|
||||
rejected(revealed, f.owner, { type: 'nexusSardaukar', event: revealed.nexusSardaukarLast!.event });
|
||||
const forces = viewGame(revealed, f.owner).battle!.ownForces!;
|
||||
assert.equal(forces.normal, 5);
|
||||
assert.equal(forces.elite, 0);
|
||||
assert.equal(forces.temporaryElite, 5);
|
||||
assert.equal(forces.eliteStrength, 2);
|
||||
assert.equal(forces.freeSupport, false);
|
||||
assert.equal(maxCombatDial(forces), 10);
|
||||
assert.equal(revealed.battle!.plans[f.owner].support, 2);
|
||||
assert.deepEqual(player(revealed, f.owner).elites, before.elites,
|
||||
'Cunning never replaces the native elite reserve stock or creates elite counters');
|
||||
const cleanup = finishIndependentNexusBattle(revealed);
|
||||
const own = player(cleanup, f.owner), other = player(cleanup, f.opponent);
|
||||
assert.equal(cleanup.lastBattleContext!.winner, f.owner);
|
||||
assert.equal(own.reserves, before.reserves - 3, 'Reinforcements consumes exactly three ordinary reserve counters');
|
||||
assert.equal(own.tanks, before.tanks + 5, 'Three reserve fees and two committed temporary-Sardaukar counters are distinct ordinary losses');
|
||||
assert.equal(own.forces[f.key], 3);
|
||||
assert.equal(own.spice, before.spice - 2 + 2, 'Support2 is paid before native Carthag Collection2');
|
||||
assert.deepEqual(own.elites, before.elites);
|
||||
assert.equal(other.reserves, enemy.reserves + 9, 'Harass returns the nine actual undialed enemy board counters');
|
||||
assert.equal(other.tanks, enemy.tanks + 1);
|
||||
assert.equal(other.forces[f.key] ?? 0, 0);
|
||||
assert.equal(other.spice, enemy.spice - 1);
|
||||
assert.equal(cleanup.nexusSardaukarHistory!.length, 1);
|
||||
assert.equal(cleanup.nexusSardaukarHistory![0].casualties!.outcome, 'complete');
|
||||
assert.equal(cleanup.nexusCards!.cards!.discard.filter(card => card === 'emperor').length, 1);
|
||||
assert.equal(cleanup.discard.filter(card => card.id === REINFORCEMENTS_CARD).length, 1);
|
||||
assert.equal(cleanup.discard.filter(card => card.id === HARASS_WITHDRAW_CARD).length, 1);
|
||||
for (const seat of cleanup.players)
|
||||
assert.equal(seat.reserves + seat.tanks + Object.values(seat.forces).reduce((sum, count) => sum + count, 0), 20);
|
||||
assert.equal(cleanup.turn, 2);
|
||||
assert.equal(cleanup.phase, 7);
|
||||
assert.equal(viewGame(cleanup, f.owner).nexusSardaukar!.active, false);
|
||||
});
|
||||
|
||||
void test('a genuinely dealt opposing Karama spends Cunning without converting physical fighters, and invalid over-dial plans remain immutable', () => {
|
||||
const f = createClassicIndependentNexusBattleFixture();
|
||||
const before = player(f.game, f.owner);
|
||||
rejected(f.game, f.owner, { type: 'battlePlan', leader: f.ownerLeader,
|
||||
dial: 11, support: 0, weapon: REINFORCEMENTS_CARD, defense: null });
|
||||
const revealed = revealIndependentNexusBattle(f, { cunning: true, canceled: true });
|
||||
const forces = viewGame(revealed, f.owner).battle!.ownForces!;
|
||||
assert.equal(forces.temporaryElite ?? 0, 0);
|
||||
assert.equal(maxCombatDial(forces), 5);
|
||||
assert.equal(revealed.battle!.plans[f.owner].support, 4);
|
||||
assert.equal(revealed.nexusSardaukarLast!.stage, 'canceled');
|
||||
const cleanup = finishIndependentNexusBattle(revealed);
|
||||
const own = player(cleanup, f.owner);
|
||||
assert.equal(cleanup.lastBattleContext!.winner, f.owner);
|
||||
assert.equal(own.forces[f.key], 1, 'The same dial now loses four ordinary board counters, not two temporary Sardaukar');
|
||||
assert.equal(own.tanks, before.tanks + 7);
|
||||
assert.equal(own.reserves, before.reserves - 3, 'Cancellation cannot refund or duplicate the separate Reinforcements cost');
|
||||
assert.equal(own.spice, before.spice - 4 + 2, 'Canceled conversion pays support4 before native Carthag Collection2');
|
||||
assert.deepEqual(own.elites, before.elites);
|
||||
assert.equal(player(cleanup, f.opponent).reserves, player(f.game, f.opponent).reserves + 9);
|
||||
assert.equal(cleanup.discard.filter(card => card.id === f.counter).length, 1);
|
||||
assert.equal(cleanup.nexusCards!.cards!.discard.filter(card => card === 'emperor').length, 1);
|
||||
assert.equal(cleanup.nexusCards!.cards!.hands[f.owner], null);
|
||||
assert.equal(cleanup.phase, 7);
|
||||
});
|
||||
|
||||
void test('Basic independent battle preserves ordinary dial losses and free support rather than borrowing the Advanced Cunning advantage', () => {
|
||||
const f = createClassicIndependentNexusBattleFixture(false);
|
||||
const before = player(f.game, f.owner);
|
||||
const offer = viewGame(f.game, f.owner).nexusSardaukar!.offer!;
|
||||
rejected(f.game, f.owner, { type: 'nexusSardaukar', event: offer.event });
|
||||
const cleanup = finishIndependentNexusBattle(revealIndependentNexusBattle(f));
|
||||
const own = player(cleanup, f.owner);
|
||||
assert.equal(cleanup.lastBattleContext!.winner, f.owner);
|
||||
assert.equal(own.spice, before.spice);
|
||||
assert.equal(own.tanks, before.tanks + 7);
|
||||
assert.equal(own.forces[f.key], 1);
|
||||
assert.equal(own.reserves, before.reserves - 3);
|
||||
assert.equal(player(cleanup, f.opponent).reserves, player(f.game, f.opponent).reserves + 9);
|
||||
assert.equal(player(cleanup, f.opponent).tanks, player(f.game, f.opponent).tanks + 1);
|
||||
assert.equal(cleanup.nexusCards!.cards!.hands[f.owner], 'emperor', 'The blocked Advanced ability cannot spend a genuinely drawn Basic Nexus card');
|
||||
assert.equal(cleanup.phase, 7);
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, type Action, type Game } from '../game/engine';
|
||||
import { normalRevivalAllowance } from '../game/revival';
|
||||
import { advanceClassicIndependentNexus } from './fixture-classic-independent-nexus';
|
||||
import { classicDiscoveryNexusClean } from './fixture-discovery-classic-nexus';
|
||||
import { createIndependentNexusBankPurchase, createIndependentNexusRecruitsRevival, utilityAction, utilityPlayer } from './fixture-classic-independent-nexus-utility';
|
||||
|
||||
function rejected(game: Game, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, 'atreides', action));
|
||||
assert.deepEqual(game, before, 'A rejected consumer action cannot spend cards, counters or money');
|
||||
}
|
||||
const quiet = (game: Game) => advanceClassicIndependentNexus(game,
|
||||
state => state.phase === 4 && classicDiscoveryNexusClean(state));
|
||||
|
||||
void test('real Recruits and Tleilaxu draw: fixed paid five precedes remaining ordinary free returns', () => {
|
||||
const f = createIndependentNexusRecruitsRevival();
|
||||
const before = utilityPlayer(f.game);
|
||||
assert.equal(utilityPlayer(f.afterBattle).tanks, 8);
|
||||
assert.equal(before.leaders.find(disc => disc.id === f.leader)!.dead, true);
|
||||
let game = utilityAction(f.game, 'atreides', { type: 'card', card: 'ecaz-recruits' });
|
||||
const rate = normalRevivalAllowance(game, utilityPlayer(game));
|
||||
assert.equal(rate.freeRate, 4);
|
||||
assert.equal(rate.limit, 7);
|
||||
rejected(game, { ...f.action, amount: 6 });
|
||||
rejected(game, { ...f.action, leader: '' });
|
||||
game = quiet(utilityAction(game, 'atreides', f.action));
|
||||
const granted = utilityPlayer(game);
|
||||
assert.equal(granted.spice, before.spice - 5);
|
||||
assert.equal(granted.tanks, 3);
|
||||
assert.equal(granted.reserves, before.reserves + 5);
|
||||
assert.equal(granted.freeForcesRevived, 0, 'Paid card counters do not consume or refund the native free quota');
|
||||
assert.equal(granted.leaderRevived, true);
|
||||
assert.equal(granted.leaders.find(disc => disc.id === f.leader)!.dead, false);
|
||||
assert.equal(game.discard.filter(card => card.id === 'ecaz-recruits').length, 1);
|
||||
assert.equal(game.nexusCards!.cards!.discard.filter(face => face === 'tleilaxu').length, 1);
|
||||
rejected(game, { ...f.action, amount: 0 });
|
||||
game = quiet(utilityAction(game, 'atreides', { type: 'revive', amount: 2 }));
|
||||
assert.equal(utilityPlayer(game).revived, 7);
|
||||
assert.equal(utilityPlayer(game).freeForcesRevived, 2);
|
||||
assert.equal(utilityPlayer(game).spice, before.spice - 5);
|
||||
assert.equal(utilityPlayer(game).tanks, 1);
|
||||
rejected(game, { type: 'revive', amount: 1 });
|
||||
game = advanceClassicIndependentNexus(game, state => state.phase === 5 && classicDiscoveryNexusClean(state));
|
||||
assert.equal(game.turn, 2);
|
||||
assert.equal(utilityPlayer(game).spice, before.spice - 5);
|
||||
assert.equal(utilityPlayer(game).leaderRevived, true);
|
||||
});
|
||||
|
||||
void test('earlier ordinary paid returns consume Recruits seven ceiling without a Secret Ally refund', () => {
|
||||
const f = createIndependentNexusRecruitsRevival();
|
||||
const before = utilityPlayer(f.game);
|
||||
let game = utilityAction(f.game, 'atreides', { type: 'card', card: 'ecaz-recruits' });
|
||||
game = quiet(utilityAction(game, 'atreides', { type: 'revive', amount: 5 }));
|
||||
assert.equal(utilityPlayer(game).spice, before.spice - 2);
|
||||
assert.equal(utilityPlayer(game).freeForcesRevived, 4);
|
||||
rejected(game, { ...f.action, amount: 3 });
|
||||
game = quiet(utilityAction(game, 'atreides', { ...f.action, amount: 2 }));
|
||||
assert.equal(utilityPlayer(game).spice, before.spice - 4);
|
||||
assert.equal(utilityPlayer(game).revived, 7);
|
||||
assert.equal(utilityPlayer(game).freeForcesRevived, 4);
|
||||
assert.equal(utilityPlayer(game).tanks, 1);
|
||||
assert.equal(utilityPlayer(game).leaderRevived, true);
|
||||
rejected(game, { type: 'revive', amount: 1 });
|
||||
});
|
||||
|
||||
void test('real Emperor draw and selected independent bank lot retain Pay-or-Use and one-card continuation', () => {
|
||||
const f = createIndependentNexusBankPurchase();
|
||||
const before = utilityPlayer(f.game);
|
||||
const card = f.game.auction!.cards[f.game.auction!.index];
|
||||
assert.equal(card.id, 'ecaz-reinforcements');
|
||||
rejected(f.beforeBid, { type: 'bid', amount: utilityPlayer(f.beforeBid).spice + 1 });
|
||||
const paid = utilityAction(f.game, 'atreides', { type: 'decision', karama: false });
|
||||
assert.equal(utilityPlayer(paid).spice, before.spice - 2);
|
||||
assert.equal(paid.nexusCards!.cards!.hands.atreides, 'emperor');
|
||||
assert.equal(utilityPlayer(paid).hand.filter(held => held.id === card.id).length, 1);
|
||||
let used = utilityAction(f.game, 'atreides', f.action);
|
||||
assert.equal(utilityPlayer(used).spice, before.spice);
|
||||
assert.equal(utilityPlayer(used).hand.length, before.hand.length + 1);
|
||||
assert.deepEqual(utilityPlayer(used).hand.find(held => held.id === card.id), card);
|
||||
assert.equal(used.nexusCards!.cards!.hands.atreides, null);
|
||||
assert.equal(used.nexusCards!.cards!.discard.filter(face => face === 'emperor').length, 1);
|
||||
assert.equal(used.nexusEmperorPurchaseHistory![0].price, 2);
|
||||
assert.equal(used.nexusEmperorPurchaseHistory![0].card, card.id);
|
||||
assert.equal(used.nexusEmperorPurchaseHistory![0].beforeSpice, before.spice);
|
||||
assert.equal(used.nexusEmperorPurchaseHistory![0].afterSpice, before.spice);
|
||||
rejected(used, f.action);
|
||||
used = advanceClassicIndependentNexus(used, state => state.phase === 4 && classicDiscoveryNexusClean(state));
|
||||
assert.equal(used.turn, 2);
|
||||
assert.equal(used.auction, null);
|
||||
assert.equal(utilityPlayer(used).hand.filter(held => held.id === card.id).length, 1);
|
||||
assert.equal(utilityPlayer(used).spice, before.spice);
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { territory } from '../game/board';
|
||||
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
|
||||
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
|
||||
import { createClassicIndependentNexusFixture, advanceClassicIndependentNexus,
|
||||
type ClassicIndependentNexusStep } from './fixture-classic-independent-nexus';
|
||||
import { nextClassicDiscoveryNexusStep, applyClassicDiscoveryNexusStep } from './fixture-discovery-classic-nexus';
|
||||
|
||||
export const independentNexusBattleClean = (game: Game) =>
|
||||
!game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
|
||||
export const independentNexusBattlePlayer = (game: Game, actor: string) => game.players.find(player => player.id === actor)!;
|
||||
export function stepIndependentNexusBattle(game: Game, actor: string, action: Action,
|
||||
actions?: ClassicIndependentNexusStep[]): Game {
|
||||
return applyClassicDiscoveryNexusStep(game, { actor, action }, actions);
|
||||
}
|
||||
|
||||
export type ClassicIndependentNexusBattleFixture = {
|
||||
lobby: Game; setup: Game; game: Game;
|
||||
actions: ClassicIndependentNexusStep[]; staging: string[];
|
||||
beforeDraw: Game; afterDraw: Game; beforeShipment: Game; afterShipment: Game;
|
||||
owner: string; opponent: string; counter: string; key: string;
|
||||
ownerLeader: string; opponentLeader: string;
|
||||
};
|
||||
|
||||
/** Only unopened Spice/Nexus singletons are positioned. Original starting
|
||||
* forces, private cards, alliances, wallets and clocks are never rewritten. */
|
||||
export function createClassicIndependentNexusBattleFixture(advanced = true): ClassicIndependentNexusBattleFixture {
|
||||
const counter = baseDeck().find(card => card.effect === 'karama')!.id;
|
||||
const original = createClassicIndependentNexusFixture({ advanced,
|
||||
roster: ['emperor', 'harkonnen', 'guild'], startingCards: {
|
||||
emperor: [REINFORCEMENTS_CARD], harkonnen: [HARASS_WITHDRAW_CARD, counter],
|
||||
} });
|
||||
const actions = [...original.actions];
|
||||
let game = original.game;
|
||||
// Turn one is peaceful. The next unopened ordinary worm creates the actual
|
||||
// turn-two Nexus; its following land cards supply the original continuation.
|
||||
const take = (worm: boolean) => {
|
||||
const index = game.spiceDeck.findIndex(card => worm ? 'worm' in card : 'territory' in card);
|
||||
assert.ok(index >= 0);
|
||||
return game.spiceDeck.splice(index, 1)[0];
|
||||
};
|
||||
const first = [take(false), ...(advanced ? [take(false)] : []), take(true), take(false), ...(advanced ? [take(false)] : [])];
|
||||
game.spiceDeck.unshift(...first);
|
||||
game = advanceClassicIndependentNexus(game, state => state.turn === 2 && state.nexus === true && !state.spiceWindow && independentNexusBattleClean(state), actions);
|
||||
game = stepIndependentNexusBattle(game, 'harkonnen', { type: 'alliance', target: 'guild' }, actions);
|
||||
game = stepIndependentNexusBattle(game, 'guild', { type: 'alliance', target: 'harkonnen' }, actions);
|
||||
game = advanceClassicIndependentNexus(game, state => state.turn === 2 && state.nexusCards?.phase?.stage === 'drawing' && independentNexusBattleClean(state), actions);
|
||||
const cards = game.nexusCards!.cards!, index = cards.deck.indexOf('emperor');
|
||||
assert.ok(index >= 0);
|
||||
cards.deck.unshift(cards.deck.splice(index, 1)[0]);
|
||||
const beforeDraw = structuredClone(game);
|
||||
game = stepIndependentNexusBattle(game, 'emperor', { type: 'nexusCardChoice', turn: game.turn,
|
||||
card: cards.hands.emperor, choice: 'draw', ownRedraws: 0 }, actions);
|
||||
const afterDraw = structuredClone(game);
|
||||
game = advanceClassicIndependentNexus(game, state => state.phase === 5 && state.active === 'emperor' && independentNexusBattleClean(state), actions);
|
||||
const sector = territory('carthag').sectors[0];
|
||||
assert.ok(!territory('carthag').sectors.includes(game.storm));
|
||||
const beforeShipment = structuredClone(game);
|
||||
game = stepIndependentNexusBattle(game, 'emperor', { type: 'ship', territory: 'carthag', sector,
|
||||
amount: 5, elite: 0, allyPayment: 0 }, actions);
|
||||
game = advanceClassicIndependentNexus(game, state => state.phase === 6 && independentNexusBattleClean(state), actions);
|
||||
const afterShipment = structuredClone(game);
|
||||
assert.ok(game.active === 'emperor' || game.active === 'harkonnen');
|
||||
game = stepIndependentNexusBattle(game, game.active!, { type: 'chooseBattle', territory: 'carthag',
|
||||
target: game.active === 'emperor' ? 'harkonnen' : 'emperor' }, actions);
|
||||
for (let count = 0; count < 100; count++) {
|
||||
const next = nextClassicDiscoveryNexusStep(game);
|
||||
if (!next) break;
|
||||
game = applyClassicDiscoveryNexusStep(game, next, actions);
|
||||
}
|
||||
assert.ok(game.battle && !game.battle.revealed && independentNexusBattleClean(game));
|
||||
const leader = (actor: string) => independentNexusBattlePlayer(game, actor).leaders
|
||||
.filter(disc => !disc.dead && !disc.usedAt).sort((one, two) => two.strength - one.strength)[0].id;
|
||||
return { ...original, game, actions, beforeDraw, afterDraw, beforeShipment, afterShipment,
|
||||
owner: 'emperor', opponent: 'harkonnen', counter, key: `carthag:${sector}`,
|
||||
ownerLeader: leader('emperor'), opponentLeader: leader('harkonnen') };
|
||||
}
|
||||
|
||||
export function declareIndependentNexusCunning(fixture: ClassicIndependentNexusBattleFixture, game = fixture.game) {
|
||||
const offer = viewGame(game, fixture.owner).nexusSardaukar!.offer!;
|
||||
assert.equal(offer.blocked, null);
|
||||
return stepIndependentNexusBattle(game, fixture.owner, { type: 'nexusSardaukar', event: offer.event });
|
||||
}
|
||||
|
||||
export function revealIndependentNexusBattle(fixture: ClassicIndependentNexusBattleFixture,
|
||||
options: { cunning?: boolean; canceled?: boolean } = {}) {
|
||||
let game = structuredClone(fixture.game);
|
||||
if (options.cunning) {
|
||||
game = declareIndependentNexusCunning(fixture, game);
|
||||
if (options.canceled)
|
||||
game = stepIndependentNexusBattle(game, fixture.opponent, { type: 'card', card: fixture.counter, mode: 'cancel' });
|
||||
game = advanceClassicIndependentNexus(game, independentNexusBattleClean);
|
||||
}
|
||||
const support = game.advanced ? options.cunning && !options.canceled ? 2 : 4 : 0;
|
||||
const plans: ClassicIndependentNexusStep[] = [
|
||||
{ actor: fixture.owner, action: { type: 'battlePlan', leader: fixture.ownerLeader,
|
||||
dial: 4, support, weapon: REINFORCEMENTS_CARD, defense: null } },
|
||||
{ actor: fixture.opponent, action: { type: 'battlePlan', leader: fixture.opponentLeader,
|
||||
dial: 1, support: game.advanced ? 1 : 0, weapon: HARASS_WITHDRAW_CARD, defense: null } },
|
||||
];
|
||||
for (const plan of plans) {
|
||||
game = stepIndependentNexusBattle(game, plan.actor, plan.action);
|
||||
while (game.response || game.phaseOpening || game.decision?.kind === 'fullPlanOffer') {
|
||||
const next = nextClassicDiscoveryNexusStep(game); assert.ok(next);
|
||||
game = applyClassicDiscoveryNexusStep(game, next);
|
||||
}
|
||||
}
|
||||
assert.ok(game.battle?.revealed);
|
||||
return game;
|
||||
}
|
||||
|
||||
export function finishIndependentNexusBattle(game: Game) {
|
||||
return advanceClassicIndependentNexus(game, state => state.phase === 7 && independentNexusBattleClean(state));
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import { treacheryDeck } from '../game/cards';
|
||||
import { tleilaxuSecretAllyOptions } from '../game/nexus-tleilaxu-revival';
|
||||
import { createClassicIndependentNexusFixture, advanceClassicIndependentNexus, type ClassicIndependentNexusStep } from './fixture-classic-independent-nexus';
|
||||
import { applyClassicDiscoveryNexusStep, classicDiscoveryNexusClean, nextClassicDiscoveryNexusStep } from './fixture-discovery-classic-nexus';
|
||||
|
||||
export const utilityPlayer = (game: Game, id = 'atreides') => {
|
||||
const player = game.players.find(seat => seat.id === id);
|
||||
assert.ok(player);
|
||||
return player;
|
||||
};
|
||||
export function utilityAction(game: Game, actor: string, action: Action, actions?: ClassicIndependentNexusStep[]) {
|
||||
return applyClassicDiscoveryNexusStep(game, { actor, action }, actions);
|
||||
}
|
||||
|
||||
/** Reorder only the unopened physical Spice and Nexus singletons. Every hand,
|
||||
* death, wallet, alliance and phase below is produced by an original action. */
|
||||
function drawUtilityNexus(game: Game, face: FactionId, actions: ClassicIndependentNexusStep[]) {
|
||||
const worm = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper);
|
||||
assert.ok(worm >= 0);
|
||||
game.spiceDeck.unshift(game.spiceDeck.splice(worm, 1)[0]);
|
||||
game = advanceClassicIndependentNexus(game, state => state.nexus && !state.spiceWindow &&
|
||||
!state.spiceResolution && classicDiscoveryNexusClean(state), actions);
|
||||
const others = game.players.filter(seat => seat.id !== 'atreides');
|
||||
game = utilityAction(game, others[0].id, { type: 'alliance', target: others[1].id }, actions);
|
||||
game = utilityAction(game, others[1].id, { type: 'alliance', target: others[0].id }, actions);
|
||||
game = advanceClassicIndependentNexus(game, state => state.nexusCards?.phase?.stage === 'drawing', actions);
|
||||
const cards = game.nexusCards!.cards!;
|
||||
const index = cards.deck.indexOf(face);
|
||||
assert.ok(index >= 0);
|
||||
cards.deck.unshift(cards.deck.splice(index, 1)[0]);
|
||||
const beforeDraw = structuredClone(game);
|
||||
game = utilityAction(game, 'atreides', { type: 'nexusCardChoice', turn: game.turn,
|
||||
card: cards.hands.atreides, choice: 'draw', ownRedraws: 0 }, actions);
|
||||
return { game, beforeDraw, afterDraw: structuredClone(game) };
|
||||
}
|
||||
|
||||
export function createIndependentNexusRecruitsRevival() {
|
||||
const weapon = treacheryDeck([]).find(card => card.kind === 'projectile')!;
|
||||
const guildCard = treacheryDeck([]).find(card => card.kind === 'worthless')!;
|
||||
const fixture = createClassicIndependentNexusFixture({ startingCards: {
|
||||
atreides: ['ecaz-recruits'], guild: [guildCard.id], emperor: [weapon.id],
|
||||
} });
|
||||
const actions = fixture.actions;
|
||||
let game = advanceClassicIndependentNexus(fixture.game, state => state.phase === 5 &&
|
||||
state.active === 'emperor' && classicDiscoveryNexusClean(state), actions);
|
||||
game = utilityAction(game, 'emperor', { type: 'ship', territory: 'arrakeen', sector: 10,
|
||||
amount: 1, elite: 0, allyPayment: 0 }, actions);
|
||||
game = advanceClassicIndependentNexus(game, state => state.phase === 6 && classicDiscoveryNexusClean(state), actions);
|
||||
if (!game.battle) game = utilityAction(game, game.active!, { type: 'chooseBattle', territory: 'arrakeen',
|
||||
target: game.active === 'atreides' ? 'emperor' : 'atreides' }, actions);
|
||||
game = advanceClassicIndependentNexus(game, state => !!state.battle && !state.battle.revealed &&
|
||||
classicDiscoveryNexusClean(state) && !nextClassicDiscoveryNexusStep(state), actions);
|
||||
const leader = utilityPlayer(game).leaders.filter(disc => !disc.dead).sort((a, b) => a.strength - b.strength)[0].id;
|
||||
const opponentLeader = utilityPlayer(game, 'emperor').leaders.filter(disc => !disc.dead).sort((a, b) => a.strength - b.strength)[0].id;
|
||||
game = utilityAction(game, 'atreides', { type: 'battlePlan', dial: 8, support: 0,
|
||||
leader, weapon: null, defense: null }, actions);
|
||||
game = utilityAction(game, 'emperor', { type: 'battlePlan', dial: 0, support: 0,
|
||||
leader: opponentLeader, weapon: weapon.id, defense: null }, actions);
|
||||
game = advanceClassicIndependentNexus(game, state => state.turn === 2 && state.phase === 1 &&
|
||||
classicDiscoveryNexusClean(state) && !state.spiceSequence && !state.spiceResolution && !state.spiceWindow && !state.nexus && !state.ready.length, actions);
|
||||
const afterBattle = structuredClone(game);
|
||||
const drawn = drawUtilityNexus(game, 'tleilaxu', actions);
|
||||
game = advanceClassicIndependentNexus(drawn.game, state => state.phase === 4 && classicDiscoveryNexusClean(state), actions);
|
||||
const offer = tleilaxuSecretAllyOptions(game, utilityPlayer(game));
|
||||
const action: Action = { type: 'nexusTleilaxuRevival', event: offer.event, leader, amount: 5, elite: 0 };
|
||||
return { ...fixture, ...drawn, game, afterBattle, leader, action, actions };
|
||||
}
|
||||
|
||||
/** Separate Emperor-absent table: the independent face stays in the unused
|
||||
* original deck until the native auction reserves and delivers that one card. */
|
||||
export function createIndependentNexusBankPurchase() {
|
||||
const fixture = createClassicIndependentNexusFixture({ roster: ['atreides', 'guild', 'harkonnen'] });
|
||||
const actions = fixture.actions;
|
||||
let game = advanceClassicIndependentNexus(fixture.game, state => state.turn === 2 && state.phase === 1 &&
|
||||
classicDiscoveryNexusClean(state) && !state.spiceSequence && !state.spiceResolution && !state.spiceWindow && !state.nexus && !state.ready.length, actions);
|
||||
const drawn = drawUtilityNexus(game, 'emperor', actions);
|
||||
game = advanceClassicIndependentNexus(drawn.game, state => state.phase === 2 && classicDiscoveryNexusClean(state), actions);
|
||||
const index = game.deck.findIndex(card => card.id === 'ecaz-reinforcements');
|
||||
assert.ok(index >= 0);
|
||||
game.deck.unshift(game.deck.splice(index, 1)[0]);
|
||||
game = advanceClassicIndependentNexus(game, state => state.phase === 3 && !!state.auction && classicDiscoveryNexusClean(state), actions);
|
||||
let beforeBid: Game | undefined;
|
||||
for (let count = 0; game.decision?.kind !== 'auctionPayment' && count < 100; count++) {
|
||||
if (classicDiscoveryNexusClean(game) && game.auction?.active === 'atreides' && game.auction.bid === 0) {
|
||||
beforeBid = structuredClone(game);
|
||||
game = utilityAction(game, 'atreides', { type: 'bid', amount: 2 }, actions);
|
||||
}
|
||||
else {
|
||||
const step = nextClassicDiscoveryNexusStep(game); assert.ok(step);
|
||||
game = applyClassicDiscoveryNexusStep(game, step, actions);
|
||||
}
|
||||
}
|
||||
assert.equal(game.decision?.kind, 'auctionPayment');
|
||||
assert.equal(game.decision.player, 'atreides');
|
||||
const offer = viewGame(game, 'atreides').nexusEmperorSecretAlly!;
|
||||
const action: Action = { type: 'nexusEmperorPurchase', event: offer.event };
|
||||
assert.ok(beforeBid);
|
||||
return { ...fixture, ...drawn, game, beforeBid, action, actions };
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import type { LeaderSkillId } from '../game/leader-skill-cards';
|
||||
import { treacheryDeck } from '../game/cards';
|
||||
import { ecazTreacheryCards } from '../game/ecaz-cards';
|
||||
import { NEXUS_FACTIONS } from '../game/nexus-cards';
|
||||
import { skillOfferRolls } from './fixture-independent-card-modules';
|
||||
import { nextClassicDiscoveryNexusStep, applyClassicDiscoveryNexusStep } from './fixture-discovery-classic-nexus';
|
||||
|
||||
export type ClassicIndependentNexusOptions = {
|
||||
advanced?: boolean;
|
||||
skills?: boolean;
|
||||
roster?: readonly FactionId[];
|
||||
training?: Partial<Record<FactionId, LeaderSkillId>>;
|
||||
/** Only original, still-undealt starting Treachery slots are ordered. */
|
||||
startingCards?: Partial<Record<FactionId, readonly string[]>>;
|
||||
};
|
||||
export type ClassicIndependentNexusStep = { actor: string; action: Action };
|
||||
|
||||
export function createClassicIndependentNexusFixture(options: ClassicIndependentNexusOptions = {}) {
|
||||
const roster = options.roster ?? ['atreides', 'guild', 'emperor'];
|
||||
const advanced = options.advanced ?? false;
|
||||
let game = createGame('CLASSICINDEPENDENTNEXUS', newPlayer(roster[0], roster[0], roster[0]), advanced, []);
|
||||
for (const faction of roster.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
|
||||
const actions: ClassicIndependentNexusStep[] = [], staging: string[] = [];
|
||||
const act = (actor: string, action: Action) => { actions.push({ actor, action }); game = applyAction(game, actor, action); };
|
||||
for (const option of ['ecazTreachery', 'nexusCards', ...(options.skills ? ['leaderSkills'] : [])])
|
||||
act(game.host, { type: 'moduleOption', module: option, enabled: true });
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game);
|
||||
const rolls = options.skills ? skillOfferRolls(game, options.training) : [];
|
||||
rolls.push(...Array<number>(NEXUS_FACTIONS.length - 1).fill(0xffffffff));
|
||||
if (options.startingCards) {
|
||||
const source = [...treacheryDeck([]), ...ecazTreacheryCards()].map(card => card.id);
|
||||
const used = new Set<string>(), prefix: string[] = [];
|
||||
const reserved = new Set(Object.values(options.startingCards).flatMap(cards => cards ?? []));
|
||||
for (const player of game.players) {
|
||||
const count = player.faction === 'harkonnen' ? 2 : 1;
|
||||
const desired = options.startingCards[player.faction] ?? [];
|
||||
assert.ok(desired.length <= count);
|
||||
for (let index = 0; index < count; index++) {
|
||||
const card = desired[index] ?? source.find(id => !used.has(id) && !reserved.has(id));
|
||||
assert.ok(card && source.includes(card) && !used.has(card));
|
||||
used.add(card); prefix.push(card);
|
||||
}
|
||||
}
|
||||
const target = [...prefix, ...source.filter(id => !used.has(id))], working = [...source];
|
||||
for (let index = working.length - 1; index > 0; index--) {
|
||||
const from = working.indexOf(target[index]);
|
||||
assert.ok(from >= 0 && from <= index);
|
||||
rolls.push(Math.floor((from + 0.5) * 0x100000000 / (index + 1)));
|
||||
[working[index], working[from]] = [working[from], working[index]];
|
||||
}
|
||||
staging.push('Select the original unused Treachery shuffle before starting deals; no played hand is changed.');
|
||||
}
|
||||
let cursor = 0;
|
||||
const native = crypto.getRandomValues.bind(crypto);
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
crypto.getRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
|
||||
if (array instanceof Uint32Array && array.length === 1) array[0] = rolls[cursor++] ?? 0xffffffff;
|
||||
else Reflect.apply(native, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try { act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' }); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
assert.equal(game.status, 'setup');
|
||||
const setup = structuredClone(game);
|
||||
for (let count = 0; game.status === 'setup' && count < 300; count++) {
|
||||
if (game.setupStage === 'leaderSkills') {
|
||||
const actor = Object.keys(game.leaderSkills!.offers)[0], own = viewGame(game, actor).leaderSkills!;
|
||||
const faction = game.players.find(p => p.id === actor)!.faction;
|
||||
const requested = options.training?.[faction];
|
||||
const skill = requested ?? own.offer!.cards.find(card => !own.unavailableSkills?.[card]);
|
||||
assert.ok(skill && own.offer!.cards.includes(skill) && !own.unavailableSkills?.[skill]);
|
||||
const leader = own.eligibleLeaders[0]; assert.ok(leader);
|
||||
act(actor, { type: 'leaderSkill', event: own.offer!.event, skill, leader: leader.id });
|
||||
continue;
|
||||
}
|
||||
let step: ClassicIndependentNexusStep | undefined;
|
||||
for (const player of game.players) {
|
||||
const view = viewGame(game, player.id);
|
||||
view.players.find(p => p.id === player.id)!.bot = 'Easy';
|
||||
const action = botActions(view)[0];
|
||||
if (action) { step = { actor: player.id, action }; break; }
|
||||
}
|
||||
assert.ok(step, `The original setup must expose its owned ${game.setupStage} choice`);
|
||||
act(step.actor, step.action);
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
for (const player of game.players)
|
||||
for (const [index, card] of (options.startingCards?.[player.faction] ?? []).entries())
|
||||
assert.equal(player.hand[index]?.id, card, 'The requested original card must come from its genuine starting deal');
|
||||
return { lobby, setup, game, actions, staging };
|
||||
}
|
||||
|
||||
/** Reuse the original native Nexus controller and lottery; no alternate phase
|
||||
* machine, fabricated alliance, draw, wallet, force placement or card grant. */
|
||||
export function advanceClassicIndependentNexus(game: Game, until: (state: Game) => boolean,
|
||||
actions?: ClassicIndependentNexusStep[], max = 1200): Game {
|
||||
for (let count = 0; !until(game) && count < max; count++) {
|
||||
assert.notEqual(game.status, 'finished', 'Original lifecycle ended before requested continuation');
|
||||
const step = nextClassicDiscoveryNexusStep(game);
|
||||
assert.ok(step, `Use the real sealed control at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
game = applyClassicDiscoveryNexusStep(game, step, actions);
|
||||
}
|
||||
assert.ok(until(game), 'Reach the original independent-Nexus continuation');
|
||||
return game;
|
||||
}
|
||||
@@ -27,8 +27,12 @@ function resolve(g: Game, callA = false, callD = false) {
|
||||
for (const p of g.players)
|
||||
assert.deepEqual(viewGame(serialized, p.id), viewGame(g, p.id));
|
||||
const before = JSON.stringify(g);
|
||||
const done = applyAction(g, 'd', { type: 'traitorCall', call: callD });
|
||||
let done = applyAction(g, 'd', { type: 'traitorCall', call: callD });
|
||||
assert.equal(JSON.stringify(g), before);
|
||||
if (done.decision?.kind === 'moritaniAssassinate') {
|
||||
const decision = done.decision;
|
||||
done = applyAction(done, decision.player, { type: 'decision', event: decision.event, decline: true });
|
||||
}
|
||||
harassCustody(done);
|
||||
return done;
|
||||
}
|
||||
@@ -86,7 +90,6 @@ void test('paired Ecaz/Moritani battles spend one physical Reinforcements cost w
|
||||
assert.equal(done.battle, null);
|
||||
assert.equal(done.players[0].reserves, 12);
|
||||
assert.equal(done.discard.filter(card => card.id === cardId).length, 1);
|
||||
assert.equal(done.log.filter(event => event.text.includes('used Reinforcements: three reserve forces')).length, 1);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user