Compose classic Nexus with Tech and Advanced Stronghold Cards

This commit is contained in:
Quintine committed 2026-10-04 17:01:00 +11:00
1 parent b8585cdd92
commit 8ceee7afd0
17 files changed
+1250 -28

No files matched your search

+22
View File
@@ -16,6 +16,28 @@ open mode gates.
## Current checkpoint and work
**Classic Nexus / Tech / Strongholds — bounded Development Verified:**
fresh classic/base33/all12 Nexus composes Tech3..6 Basic/Advanced and/or
Advanced Strongholds2..6, without Skills, public starts or new rulings.
New24/affected188, final34 guide/module cases, types/lint/build, fourteen
actual programs and all four legal free-return policies pass. Eight genuine
four/five-seat games complete4,440 accepted actions/no rejection/116 JSON
continuations; five-seat samples include five real closing Nexus choices.
Original CLI **DPA6UGWK v10 / HXEMRA32,FQNN62UG v8** keeps undealt setup.
Human390px **HXEMRA32 v13/movement** returns2 normal+1 actual Sardaukar,
ordinary/free3 and elite1; real Axlotl1 stays pending at wallet12 then collects13.
**FQNN62UG v14/Mentat** ships5 for original Guild3 (wallet12→9),
Heighliners1 stays unavailable until real phase end and pays Harkonnen10→11.
**DPA6UGWK v18/Collection** pays bank2/own4 for support6, keeps Shield,
loses6 ordinary/no starred counters and retains1 Arrakeen survivor. Guild
spends Moritani to retain played Crysknife before original Heighliners transfer;
Emperor spends CHOAM to inspect only unused Trip to Gamont. Separate city2
yields wallet8. Actual controls, inspector, refresh and guide have no root390px
overflow. Controlled positions are labeled; no natural free-return/inspection
claim. [Scope and evidence](NEXUS_CARD_RULES.md#classic-tech-and-stronghold-composition--4-october-2026)
retain native Fremen on-planet and native-Guild-only industry exclusions.
Other modules, unresolved effects, full/public/deployed acceptance remain open.
**Native Tech / Strongholds without Skills — bounded Development Verified:**
fresh selected E1/E2 native or standalone Ecaz OR Moritani entry preserves
original Tech3..6 Basic/Advanced and optional Advanced Strongholds, without
+39
View File
@@ -1,5 +1,44 @@
# Dune implementation status
## 4 October 2026 — classic Nexus with Tech and Advanced Strongholds
Two bounded owners supplied original shipment/revival/purchase and coupled
battle programs. Parent integrated fresh classic/base33/all12 admission into
the existing Nexus seam, original Axlotl hooks, source identity exclusions,
sample/resume/entry guidance and human controls. Tech3..6 Basic/Advanced,
Strongholds2..6 Advanced, both3..6; Skills, HW, native expansions, other
previews, public starts and existing saves remain outside this composition.
New24/affected188, final34 module/guide, types/lint/build and fourteen actual
rule programs pass. Four legal free-return policies retain exact3 ledgers,
real elite cap and deferred Axlotl. Parent rejected a proposed paid-Fremen
Heighliners trigger: authorized physical32 expressly keeps reserves on-planet,
and a borrowed tariff is not a new physical origin. Original exclusion remains.
Both real free batches activate Axlotl; Emperor extra versus Fremen ordinary
allowance stays distinct. Existing bank purchase keeps real wallet/card and
does not create industry income. No unresolved ruling was decided.
Eight genuine4/5-seat module games complete4,440 accepted actions, no
rejection and116 JSON continuations. Five-seat samples execute five closing
Nexus choices; four-seat samples can ally everyone and deal none. Reports
capture production unchanged on trees
`13367864d40cd778e5b50b5610e97b5819c25f74f37c655d962a884e3314cb28` and
`ea4f523a732c7c958c4df88fa01f8c335d7c896975348e5403306eda956df012`.
Later fixture-only undealt-CLI continuation/guide edits do not change engine.
No natural free-return or private-inspection claim is made.
Original CLI DPA6UGWKv10/HXEMRA32,FQNN62UGv8 keeps genuine unused setup
and exact seats. Human390px HXEMRA32v13/movement returns2normal+1Sardaukar,
ordinary/free3/elite1, pending Axlotl1 at wallet12 then13 at real phase end.
FQNN62UGv14/Mentat pays Guild3 for5 actual counters, wallet12→9;
Heighliners1 pays Harkonnen10→11 only after original movement finishes.
DPA6UGWKv18/Collection pays bank2/own4 for support6, retains Shield and1
normal Arrakeen survivor after6 normal/no starred loss. Moritani loser retains
played Crysknife before original winner Heighliners; CHOAM reveals only unused
Trip to Gamont. Separate city2 yields wallet8. Inspector, controls, refresh
and guide are observed, root390px no overflow; controlled positions are labeled.
Wider rules/combination/assurance/calibration/public/deployed gates remain open.
## 4 October 2026 — native Tech and Advanced Strongholds without Skills
Two bounded owners supplied independent E1/E2 and standalone E3 cases.
+97
View File
@@ -2,6 +2,103 @@
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 Tech and Stronghold composition — 4 October 2026
The existing fresh local `nexus` entry preserves unused original Tech Tokens
with classic/base33/all12 Nexus3..6 Basic/Advanced, and unused Stronghold Cards
with classic Nexus2..6 Advanced; selecting both requires3..6 Advanced.
Original all12 setup, qualifying closing Nexus draws, native first-Storm Tech
assignment and actual end-Mentat Stronghold claims remain. No Skills,
Homeworlds, native expansion decks/factions, independent cards, unrelated
previews, public starts or saved-game conversion are admitted by this bound.
This composes original printed sources: the supplied authorized Advanced
PDF physical21/printed19 permits Tech and Stronghold variants together or
separately, and physical23/printed21 gives the independent Nexus lifecycle.
The original photographed Nexus faces supply the borrowed effects.
Original Guild/Richese borrowed shipments change tariff, not faction or reserve
origin. Non-Fremen/non-Guild off-planet arrivals activate Heighliners once;
native Guild-only arrivals retain its printed exception. A paid borrowed Guild
route does not move native Fremen's southern reserves off-planet: physical32/
printed30 expressly keeps that source on Dune, so it does not trigger Heighliners.
Stopped shipments never reach payment/arrival or industry activity.
Both actual three-force Nexus free returns now use the existing Axlotl hook
after physical transfer. Fremen consumes the ordinary force/free ledger;
Emperor remains additional beyond those ledgers. Both retain the Advanced
one-elite cap and private current-event legality. Tech spice remains on its
token until the original Revival phase ends; a second qualifying return does
not accrue again. Stronghold Cards add no revival bonus. Emperor's existing
normal bank-auction purchase preserves the actual buyer wallet/card delivery
and does not activate any industry.
Original held Arrakeen subsidizes actual battle support, never personal income.
Emperor Cunning's temporary Sardaukar remain physically ordinary counters.
Defeated Moritani Secret Ally retention finishes before original winner Tech;
CHOAM winner inspection then excludes all actually played opposing cards,
including a retained one. Card spend, casualties, income and cleanup remain
single original events. Ordinary city Collection is separate from battle costs.
Borrowed Voice, Fremen worm-protection reaction policy, Tleilaxu revival
accounting and other unresolved interpretations remain guarded. This is
bounded composition, not complete Nexus/faction/module or release acceptance.
Genuine offline samples:
```sh
node --import tsx tools/faction-games.ts --profile nexus-tech \
--players 5 --rules both --out /private/new-nexus-tech-games
node --import tsx tools/faction-games.ts --profile nexus-stronghold \
--players 5 --rules advanced --out /private/new-nexus-stronghold-games
node --import tsx tools/faction-games.ts --profile nexus-stronghold-tech \
--players 5 --rules advanced --out /private/new-nexus-three-module-games
```
Three actors suffice for one unallied receiver after a real alliance; four or
more allow two separate receivers for coupled postbattle effects. Controlled
positions conserve original cards/counters after genuine setup and actual
closing draws; they do not manufacture wallets, phases or module ownership.
### Bounded development evidence
New24 consumer-visible cases are included in affected188/188. Final34
module/guide cases, types/lint/build, fourteen actual native rule programs
and all four legal free-return policies pass. Original cases cover both free
ledgers, elite cap, typed tariff/arrival, stopped/native source exclusions,
bank purchase, held subsidy, temporary ordinary-as-starred losses and coupled
loser retention/original-winner token/private-unused-card inspection.
Eight genuine4/5-seat games complete4,440 accepted actions, no rejection
and116 JSON continuations. The four five-seat samples include five actual
closing Nexus choices; four-seat samples may pair every seat and draw none.
These do not claim natural free-return/inspection usage. Report roots:
`/tmp/dune-classic-nexus-tech-games-20261004`,
`/tmp/dune-classic-nexus-stronghold-games-20261004`,
`/tmp/dune-classic-nexus-stronghold-tech-games-20261004`
and their `-five-games-20261004` counterparts.
Reports capture unchanged production source; later fixture continuation and
guide changes do not modify the game engine.
Original CLI **DPA6UGWK v10/setup**, **HXEMRA32/FQNN62UG v8/setup**
preserves unused selected modules and exact authenticated actors.
Controlled390px **HXEMRA32 v13/movement** returns2 normal/1 actual Sardaukar,
consuming ordinary/free3 and elite1; Axlotl1 stays pending while wallet12,
then original phase end collects13. **FQNN62UG v14/Mentat** sends5 physical
Atreides counters for Guild3 (wallet12→9); Harkonnen Heighliners1 is unavailable
until native movement ends, then wallet10→11 once.
**DPA6UGWK v18/Collection** seals Fenring6/dial6/support6/Shield in held
Arrakeen. Bank2/own4 is a subsidy, not income. Six ordinary losses leave1,
with all5 starred reserves untouched. Guild loses6, uses Moritani to keep
played Crysknife, then original Heighliners transfers to Emperor before
CHOAM inspection. Emperor sees only unused Trip to Gamont, not the retained
used weapon; both remain in Guild hand. Separate Arrakeen Collection2 makes
wallet8. Human selection, inspector, readiness, public pending/owner data,
refresh and updated guide are observed; root390px has no overflow.
Dedicated QA storage preserves every original room; no user saves are touched.
Full combinations/rules/assurance/calibration/public/deployed gates remain open.
## Authority and acquisition
- [GF9 Ecaz & Moritani rulebook](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf), printed pp.4, 9, 11 and 16: component count, independent variants, common lifecycle, Atreides example and two Nexus FAQ answers. Official-domain indexed text was inspected, but direct retrieval returned 403 during this audit. The coordinator successfully fetched the [publisher-authored PDF mirror](https://gamers-hq.de/media/pdf/0f/7a/86/Dune_EcazMoritani_Rulebook_EN.pdf) to `/tmp/dune-e3-nexus-rules.pdf` and `.txt`. This is a mirror of the publisher's rules, not a separate rules authority.
+12
View File
@@ -77,6 +77,18 @@ The FAQ retains the one-per-turn Sardaukar/Fedaykin revival cap even through Gho
E1 pays Tleilaxu for factions using free revival or Ghola; E2's La La La prevents Free Revival during Revival. Their texts do not expressly distinguish this new Nexus return from the ordinary free-revival event. Preserve the already-pending [income accounting boundary](TLEILAXU_AMBASSADOR_RULES.md) rather than minting another payment or assuming immunity. No source gives this card a special exception to Tleilaxu's revival prohibition. [E1, p.7](https://www.gf9games.com/dunegame/wp-content/uploads/2020/09/IxianAndTleilaxuRulebook.pdf#page=7), [E2, p.7](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf#page=7)
Fresh supported classic Nexus may additionally preserve original Tech and/or
Advanced Stronghold Cards. This actual free batch invokes the original Axlotl
hook, independently of its extra-versus-ordinary allowance distinction, and
collects only at the native Revival phase end. Subsequent ordinary free return
does not accrue twice. Strongholds add no revival bonus. The same supported
profile preserves the existing bank-auction purchase and its original buyer
wallet/card delivery; that purchase is not an industry trigger. Native Emperor
absence, exact-three/elite caps and quiet source restrictions remain.
See [scope and authority](NEXUS_CARD_RULES.md#classic-tech-and-stronghold-composition--4-october-2026);
this does not admit native Tleilaxu income, Homeworlds, Skills or seller-paid
and forced-Emperor producers, and leaves their pending questions unchanged.
## Betrayal and remaining decisions
Normal Emperor-funded auction help goes to the ally and is paid back to Emperor, according to November's FAQ. Therefore forcing its contribution cannot automatically be implemented as transferring its spice to the bank. Bind the actual allied purchase and its original price/contributions. [November FAQ, p.2](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=2)
+1 -1
View File
@@ -101,7 +101,7 @@ A pause offered only when the threatened player secretly holds this card would d
The card's total-of-three wording and the native alliance's ordinary free-three benefit support a **free-rate interpretation within ordinary revival accounting**, rather than three additional counters beyond the usual allowance. This is source composition, not an explicit Nexus FAQ. Preserve it as the proposed contract; do not silently apply Emperor's separate extra-return producer.
The current first path is a deliberate subset of that proposed contract: with Fremen absent, an unallied base-faction holder in Basic or Advanced Revival may spend the physical Nexus card to transfer exactly three eligible forces from Tanks to reserves at zero spice cost. It is available before any ordinary force return, with no competing expansion/module revival modifier, pending response or decision. The three returned forces consume the ordinary allowance and free-force ledger; they are **not** Emperor-style extra returns. The Advanced one-elite-per-turn cap and physical elite census apply. The server's private offer provides the eligible elite counts; the action validates its current turn and group. A signed before/after receipt and independent event markers persist through JSON saves.
This direct transfer is safe only because the scoped seats exclude Tleilaxu income/Axlotl and Homeworld arrival continuations. It must not be generalized to those modes without using the native pending-revival pipeline. If fewer than three forces are in Tanks, if a force return already occurred, or if a higher free rate or modifier applies, the option stays unavailable rather than guessing a ruling. Protection remains unimplemented; Cunning's bounded native path above is separate.
The bounded classic Tech/Advanced Stronghold composition now calls the existing Axlotl activity hook after this actual free batch; spice stays on the token until native phase-end settlement, without changing these ordinary ledgers. See [the composition contract](NEXUS_CARD_RULES.md#classic-tech-and-stronghold-composition--4-october-2026). Tleilaxu income and Homeworld arrivals remain excluded: those broader continuations require the native pending-revival pipeline, not this quiet transfer. Fewer-than-three Tanks, previous force returns and higher free rates/modifiers still remain unavailable rather than guessing a ruling. Protection remains unimplemented; Cunning's bounded path is separate.
`game/revival.ts` already computes free rate/remaining from `p.revived`, typed prices and ordinary limits. Engine `beginRevival`/`finishRevival` retains pending requests and Tleilaxu prevention/income continuations. The future combined-mode adapter should be an explicit source-labelled, phase-bound free-rate entitlement consumed by an actual normal revival request, retaining previous returns and exact free/paid normal/starred allocation. Never refund earlier paid revivals or reset `revived`/elite counters on card play or reload.
+1
View File
@@ -205,6 +205,7 @@ See [the cutover and Advanced sequence](MORITANI_ASSASSINATE_LEADERS.md#authorit
| Normal Spice Banker local preview | Fresh classic/base33 Basic/Advanced or supported Basic native skill profiles, all14 skills alone. One additional deferred bank spice per physical card/turn/phase for another payer's one actual bank payment ≥4; no payer pooling or advance spending. Tleilaxu self-revival pays Bank; other-player revival pays Tleilaxu unless canceled. The separate free-revival award never contributes to payer debit. Actual initialized Mentat collects once; original earned-faction retention and separate-payer threshold remain provisional local policies, not answers to pending custody/split questions. No public start, retrofit or other overlays. | [Normal-band contract](SPICE_BANKER_RUNTIME.md#normal-income-local-contract--2-october-2026), [native integration](SPICE_BANKER_RUNTIME.md#basic-native-banker-integration--2-october-2026), [skill authority](LEADER_SKILLS_RULES.md) |
| Advanced Stronghold faction integration | Fresh `stronghold-factions` admits selected E1/E2 native families and standalone Ecaz OR Moritani with classic2..6-seat Advanced, canonical selected decks and six Stronghold Cards; optional unused Tech Tokens requires3..6, without Leader Skills. Standalone E3 uses ecaz33; Moritani excludes Harkonnen. Ecaz co-control holds the card at Ecaz, but only the selected actual holder-plan receives its effect; fixed Ecaz strength is free, and Arrakeen funds only actual variable support. Losing Moritani retains printed Tuek income before original assassination, printed bounty and one actual Mentat replacement; winner cleanup finishes once and normal Traitor reveal forfeits assassination for the game. Existing E1/E2 No-Field/Stone/FaceDance and retained winner rewards remain. E3 pairs/mixed families, mixed No-Field/full-plan, Stone/Poison Tooth and other overlays stay guarded. This composes existing contracts, not a new ruling. Save/privacy/custody assurance follows all rules. | [E1/E2 scope](STRONGHOLD_CARDS.md#expanded-e1e2-faction-rules), [standalone E3 scope and evidence](STRONGHOLD_CARDS.md#standalone-e3-stronghold-composition--4-october-2026), [native Tech composition](EXPANSION_FACTIONS_PROTOTYPE.md#native-tech-composition--4-october-2026) |
| Native Tech without Leader Skills | Fresh3..6 Basic/Advanced selected E1/E2 native families or standalone Ecaz OR Moritani plus classics preserve original unused three Tech Tokens through `factions`; Advanced Strongholds use the existing native entry. Physical setup, printed/first-Storm owners, phase-end income and single-player complete-set victory are unchanged. Actual Ecaz selected lead receives the original opposing token, never a joint allied holding; Basic odd Occupy remains guarded. Moritani original forfeiture/assassination and winner Tech before Face Dance reuse existing handlers. No new ruling, wallet grant, public start or save conversion. | [Original composition/source and evidence](EXPANSION_FACTIONS_PROTOTYPE.md#native-tech-composition--4-october-2026), [Stronghold contract](STRONGHOLD_CARDS.md#native-tech-composition-without-skills--4-october-2026) |
| Classic Nexus / Tech / Strongholds | Fresh classic/base33/all12 Nexus may preserve original Tech3..6 Basic/Advanced and/or Strongholds2..6 Advanced, without Skills/HW/expansion decks or unrelated previews. Original closing deals, first-Storm Tech and actual end-Mentat card claims remain. Actual Nexus free-three returns invoke Axlotl without merging their extra/ordinary ledgers; industry collects once at native phase end. Borrowed tariffs never relocate Fremen on-planet reserves or native faction identity. Held Arrakeen pays actual support; temporary Sardaukar remain ordinary counters. Losing Moritani retention precedes original winner Tech, then CHOAM excludes every used opposing card. Bank purchase retains actual wallet/card without an industry trigger. Pending borrowed Voice, worm protection and Tleilaxu revival remain guarded; no public starts/save conversion or new ruling. | [Composition/source and bounded evidence](NEXUS_CARD_RULES.md#classic-tech-and-stronghold-composition--4-october-2026) |
| Discoveries | Preserve seven sourced Spice Cards and eight physical tokens, private faction peeks and Collection choices. Revealed locations are separate territories; only Jacurutu counts toward stronghold victory. Next-turn free entry, sole-occupant Cistern income, bounded sole-occupant Orgiz transfer and later-turn Ornithopter spending are prototyped; Jacurutu income, Testing Station and Shrine also have bounded paths. Contested and mixed cases remain pending. | [Component authority](DISCOVERY_COMPONENTS.md), [prototype boundary](DISCOVERY_PROTOTYPE.md) |
| Guild Secret Ally | One explicit Guild-priced ordinary shipment; borrowed prices/routes preserve native identity. Free Fremen reinforcement remains a separate choice. | [Route contract](NEXUS_GUILD_SECRET_ALLY_RULES.md), [verified runtime](NEXUS_GUILD_SECRET_ALLY_RUNTIME.md) |
| Homeworld custody and mobility | Physical home/visitor groups, population thresholds, tariffs and transport permissions retain distinct sources; Homeworld immunity is not universal immunity. | [Homeworld rules](HOMEWORLD_RULES.md), [benefits](HOMEWORLD_BENEFITS_RULES.md), [mobility](HOMEWORLD_MOBILITY_RULES.md), [invasion](HOMEWORLD_INVASION_RULES.md), [Guild transport](GUILD_HOMEWORLD_TRANSPORT_RULES.md) |
+11 -7
View File
@@ -1,3 +1,4 @@
import { classicNexusModulesProfile } from './nexus-module-profile';
import { nativeFactionTechProfile } from './faction-module-profile';
import { advancedMoritaniLeaderSkillsProfile, advancedNativeLeaderSkillsProfile, classicTechLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, strongholdLeaderSkillsProfile } from './leader-skill-profile';
import { bribeTimingBlock, maximumBribe, type BribeOptions } from './bribe-options';
@@ -2541,7 +2542,7 @@ function nexusEmperorSecretAllyOffer(g:Game,owner:string) {
let purchaseBlock:string|null = null;
if (p.ally) purchaseBlock = 'Emperor Nexus Secret Ally requires an unallied buyer.';
else if (!emperorNexusModeSupported(g) || g.ecazTreachery || g.sandtrout)
purchaseBlock = 'Emperor Nexus purchase currently requires classic factions and Nexus alone.';
purchaseBlock = 'Emperor Nexus purchase requires a supported classic Nexus profile; unrelated modules remain unavailable.';
else if (g.response || g.truthtrance || g.phaseOpening || g.pendingKarama ||
g.pendingTreacheryDiscard || g.pendingNullentropy || g.pendingExchange ||
g.pendingRicheseGift || g.pendingRichesePurchaseIncome || g.pendingAmbassador ||
@@ -2558,7 +2559,7 @@ function nexusEmperorSecretAllyOffer(g:Game,owner:string) {
}
let blocked:string|null = null;
if (p.ally) blocked = 'Emperor Nexus Secret Ally requires an unallied holder.';
else if (!emperorNexusModeSupported(g)) blocked = 'Emperor Nexus Secret Ally currently requires base factions and Nexus alone; expansion and other module combinations remain pending.';
else if (!emperorNexusModeSupported(g)) blocked = 'Emperor Nexus Secret Ally requires a supported classic Nexus profile; expansions and unrelated modules remain pending.';
else if (g.status !== 'playing' || g.phase !== 4) blocked = 'Use the three-force Emperor Nexus return during Revival.';
else if (g.response || g.decision || g.truthtrance || g.phaseOpening || g.pendingRevival || g.pendingKarama ||
g.pendingTreacheryDiscard || g.pendingNullentropy || g.pendingExchange || g.pendingRicheseGift ||
@@ -2615,6 +2616,7 @@ function playNexusEmperorRevive(g:Game,p:Player,action:Action) {
g.nexusCards!.cards = nexusRule(() => discardNexusCard(g.nexusCards!.cards!,p.id,g.players));
addRevivedReserves(g,p,EMPEROR_NEXUS_REVIVALS,elite); p.tanks -= EMPEROR_NEXUS_REVIVALS;
if (p.elites) {p.elites.tanks -= elite;p.elites.revived += elite;}
techIncome(g, 'axlotl', p);
const record:NexusEmperorRevival = {kind:'revival',event:offer.event,owner:p.id,turn:g.turn,phase:4,
faction:p.faction,advanced:g.advanced,
sequence:g.nexusEmperorSecretHistory?.length ?? 0,elite,before,after:emperorNexusPools(p),signature:''};
@@ -2792,6 +2794,7 @@ function playNexusFremenRevive(g: Game, p: Player, action: Action) {
p.revived += FREMEN_NEXUS_FREE_FORCES;
p.freeForcesRevived = (p.freeForcesRevived ?? 0) + FREMEN_NEXUS_FREE_FORCES;
if (p.elites) { p.elites.tanks -= elite; p.elites.revived += elite; }
techIncome(g, 'axlotl', p);
const record: FremenNexusRevival = { event: offer.event, owner: p.id, turn: g.turn,
phase: 4, faction: p.faction, advanced: g.advanced, elite, before,
after: fremenNexusRevivalPools(p), signature: '' };
@@ -9429,12 +9432,13 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
const skillStronghold = leaderSkills && strongholdLeaderSkillsProfile(g);
const skillTech = leaderSkills && (classicTechLeaderSkillsProfile(g) || nativeTechLeaderSkillsProfile(g) || skillStronghold);
const nativeTech = factions && !leaderSkills && nativeFactionTechProfile(g);
requireRule(!g.techTokens || ((skillTech || nativeTech) &&
const nexusModules = nexus && classicNexusModulesProfile(g);
requireRule(!g.techTokens || ((skillTech || nativeTech || nexusModules) &&
JSON.stringify(g.techTokens) === JSON.stringify(createTechTokens())),
'Tech Tokens require a fresh supported three-through-six-seat lobby with unused tokens.');
requireRule(!g.strongholdCards || strongholdFactions || (skillStronghold &&
requireRule(!g.strongholdCards || strongholdFactions || ((skillStronghold || nexusModules) &&
JSON.stringify(g.strongholdCards) === JSON.stringify(createStrongholdCards())),
'Leader Skills with Stronghold Cards requires a fresh supported Advanced lobby with unused cards.');
'Stronghold Cards require a fresh supported Advanced lobby with unused cards.');
requireRule(
(homeworlds || nexus || ix || choam || factions || g.expansions.length === 0) &&
(choam || factions || !g.expansions.includes('choam')) &&
@@ -9443,8 +9447,8 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
(discovery || !g.discoveryEnabled) &&
(nexus || !g.nexusCards) &&
(ecazTreachery || !g.ecazTreachery) &&
(!g.techTokens || skillTech || nativeTech) &&
(strongholdFactions || skillStronghold || !g.strongholdCards) &&
(!g.techTokens || skillTech || nativeTech || nexusModules) &&
(strongholdFactions || skillStronghold || nexusModules || !g.strongholdCards) &&
(homeworlds || !g.homeworlds) &&
g.players.every((p) =>
FACTIONS.some(
+2 -2
View File
@@ -1,5 +1,6 @@
import type { Game, Player } from './engine';
import { eliteRevivalRemaining } from './revival';
import { classicNexusModulesProfile } from './nexus-module-profile';
export const EMPEROR_NEXUS_REVIVALS = 3;
export type EmperorNexusPools = {
@@ -69,8 +70,7 @@ export function emperorNexusModeSupported(g: Game): boolean {
!g.discoveries &&
!g.discoveryStash &&
!g.greatMaker &&
!g.techTokens &&
!g.strongholdCards
((!g.techTokens && !g.strongholdCards) || classicNexusModulesProfile(g))
);
}
/** A fixed three-counter grant preserves the separate Advanced elite cap. */
+3 -2
View File
@@ -1,5 +1,6 @@
import type { Game, Player } from './engine';
import { eliteRevivalRemaining, forceRevivalLimit, freeRevivalRate } from './revival';
import { classicNexusModulesProfile } from './nexus-module-profile';
export const FREMEN_NEXUS_FREE_FORCES = 3;
type RevivalPools = { reserves: number; tanks: number; eliteReserves: number; eliteTanks: number;
@@ -38,10 +39,10 @@ export function fremenNexusRevivalOffer(g: Game, owner: string) {
let blocked: string | null = null;
if (p.ally) blocked = 'An allied player cannot use a Nexus card.';
else if (g.status !== 'playing' || g.phase !== 4) blocked = 'Use Fremen Secret Ally during Revival.';
else if (g.expansions.length || g.homeworlds || g.leaderSkills || g.discoveryEnabled ||
else if (!classicNexusModulesProfile(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(seat => !['atreides', 'harkonnen', 'emperor', 'guild', 'beneGesserit'].includes(seat.faction)))
g.players.some(seat => !['atreides', 'harkonnen', 'emperor', 'guild', 'beneGesserit'].includes(seat.faction))))
blocked = 'Combined expansion and module revival interactions remain unavailable.';
else if (g.response || g.decision || g.truthtrance || g.phaseOpening || g.pendingRevival ||
g.pendingKarama || g.pendingTreacheryDiscard || g.pendingNullentropy ||
+25
View File
@@ -0,0 +1,25 @@
import { FACTIONS } from './catalog';
import type { FactionModuleProfile } from './faction-module-profile';
/** Public classic configuration only; the initializer checks undealt physical components. */
export function classicNexusModulesProfile(game: FactionModuleProfile): boolean {
if (typeof game.advanced !== 'boolean' || !game.nexusCards ||
(!game.techTokens && !game.strongholdCards) || game.expansions.length !== 0 ||
game.players.length < 2 || game.players.length > 6 ||
(game.techTokens && game.players.length < 3) ||
(game.strongholdCards && !game.advanced) || game.leaderSkills || game.homeworlds ||
game.discoveryEnabled || game.discoveries || game.discoveryStash || game.greatMaker ||
game.ecazTreachery || game.semutaPreview || game.advancedPreview ||
game.kullPreview || game.nexusKullPreview || game.guildBetrayalPreview ||
game.richeseBetrayalPreview || game.nexusIxianReplacementPreview ||
game.nexusIxianBetrayalPreview || game.nexusHarkonnenBetrayalPreview) return false;
for (let i = 0; i < game.players.length; i++) {
const player = game.players[i];
for (let j = 0; j < i; j++) if (player.faction === game.players[j].faction) return false;
let classic = false;
for (const faction of FACTIONS)
if (faction.id === player.faction) { classic = faction.expansion === 'base'; break; }
if (!classic) return false;
}
return true;
}
+4 -1
View File
@@ -4365,6 +4365,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Before drawing, choose whether to keep your own faction card, redraw it once, or redraw whenever it appears. Its effect is available in the reference before making this choice. The server applies that preference immediately; the table never pauses in a way that identifies a card you chose to keep.',
'Entering any alliance discards your held Nexus Card, including alliances made through an Ambassador or Terror token. An offer that has not been accepted does not discard it.',
'Use Cunning for your own faction, Betrayal when another player controls the printed faction, and Secret Ally when that faction is absent. Used cards go to the separate Nexus discard pile. Each printed effect has its own timing.',
'Fresh local classic Nexus tables may preserve original Tech Tokens in Basic/Advanced three-through-six-seat lobbies and/or Stronghold Cards in Advanced two-through-six-seat lobbies; selecting both requires three or more. Original base33/all12 setup, closing Nexus deals, first-Storm Tech and actual end-Mentat Stronghold custody remain. Skills, Homeworlds in this composition, native expansions, unrelated previews, public starts and saved-game conversion stay separate.',
'Atreides Cunning adds a second, different opposing plan element after the first native answer. Both remain binding. Use it in your own battle before sealing your plan; Karama may cancel the extra attempt while preserving the first disclosure.',
'With Atreides absent, Secret Ally inspects one opposing element in your battle. A sealed plan supplies only that selected answer automatically. Otherwise the opponent chooses an answer that permits a legal plan. Other players receive no private answer.',
'The No-Field restriction names Atreides, so Cunning cannot inspect that dial. A non-Atreides Secret Ally holder may request it without learning the token denomination or other plan elements.',
@@ -4385,7 +4386,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Emperor Cunning requires an Advanced battle with at least five ordinary Emperor counters and no actual Sardaukar, before submitting your plan. The played Nexus card opens one Karama response. Allowing it makes five ordinary counters count as Sardaukar for this battle; cancellation prevents the entire enhancement and leaves the Nexus card spent.',
'Temporary Sardaukar use the applicable strength and support rules, including the Fremen exception, but remain physically ordinary counters. Their losses go to Tanks as ordinary counters, without changing starred reserves or Tanks. Existing inspected or promised plans remain binding. The role ends after this battle; fewer-than-five groups remain unavailable. The separate Secret Ally revival and normal bank-auction purchase have connected controls; seller-paid and forced Emperor payments remain unfinished.',
'Emperor Betrayal has a battle alternative in Advanced play: an unallied opponent holding the Emperor Nexus card can spend it during the ordinary pre-plan Sardaukar response to suppress actual Sardaukar strength for this battle. Every seat receives the same response opportunity even with no card, keeping hidden custody private. The starred pieces remain physical Sardaukar; no card grants a new force or cancels their separate support benefit.',
'With Fremen absent, an unallied holder may spend Fremen Secret Ally during Revival to return exactly three eligible forces from Tanks to reserves at no spice cost. Unlike Emperor Secret Ally, these three consume the ordinary force and free-force allowances, including the Advanced one-elite cap. This first path requires base factions and Nexus alone, before any normal force returns, without combined revival effects. Partial returns, prior returns, higher free rates and separate sandworm protection remain unavailable.',
'With Fremen absent, an unallied holder may spend Fremen Secret Ally during Revival to return exactly three eligible forces from Tanks to reserves at no spice cost. Unlike Emperor Secret Ally, these three consume the ordinary force and free-force allowances, including the Advanced one-elite cap. This bounded base-faction path keeps prior-return and competing-revival guards; supported classic Tech/Stronghold composition invokes actual Axlotl activity after the batch, independently of allowance accounting. Both free returns collect Tech only at phase end, never as spendable advance spice. Partial returns, prior returns, higher free rates and separate sandworm protection remain unavailable.',
'Native Fremen Cunning has a bounded Nexus-only path: when a natural, accepted additional, or Advanced special-Karama summoned worm appears in a territory initially containing no forces, Fremen may spend their own Nexus card before the worm resolves. The special summon retains its original Spice Blow parent. After the applicable Nexus, choose some forces from one occupied desert territory and ride to a legal Arrakis destination without using normal movement. The choice appears even without the secret card, so declining does not disclose custody. Karama provisionally prevents only the additional remote ride; the original worm still resolves and the card remains spent. An interrupted existing Cunning control is restored, and a selected ride waits for the summoned Nexus without granting a second Cunning offer; Great Maker and combined optional modules await integration.',
'CHOAM Secret Ally has a working Collection trade: when CHOAM is absent, an unallied holder may discard one Worthless card and the Nexus card for two bank spice. After a battle victory, every winner in an eligible Nexus game receives the same use-or-continue window; an unallied CHOAM-card holder may instead spend that card to inspect one random unused card in the opposing combatant’s current hand. Played battle cards remain excluded even if retained. The inspected face is private, while use is public. The universal window protects concealed card custody; this is an interface interpretation, not a publisher-prescribed prompt. An ordinary Karama response to this Secret Ally inspection remains unestablished.',
'With CHOAM seated, an unallied holder may spend CHOAM Betrayal at a clean play boundary to discard one uniformly random held CHOAM Treachery Card without spice compensation. The target name is public, but its hand count is offered only during Bidding; the holder cannot pick or inspect the card before the ordinary discard reveal.',
@@ -4394,6 +4395,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Moritani Cunning modifies its normal Mentat placement: choose one available supply token and a territory on the printed Arrakis board, including a territory already containing Terror. The explicit Cunning toggle keeps ordinary placement and relocation unchanged. Homeworlds, the Hidden Mobile Stronghold, Cunning relocation and combined Grumman use are unavailable.',
'With Moritani seated, an unallied holder of its Nexus card may spend Betrayal at a clean play boundary to return one placed Terror token to hidden Moritani supply without revealing its face. Choose the public location and token, not its secret effect; this does not use Moritani’s Mentat placement or trigger that token.',
'When Moritani is absent, a defeated combatant with an eligible publicly played card receives the same postbattle choice whether or not they hold Moritani Nexus. An unallied holder may spend it to retain one played card that a winner could keep; other played cards follow ordinary discard. Without a publicly eligible card there is no prompt. The choice is after the battle result and before later income/capture; the public prompt protects hidden custody and is an interface inference. This Secret Ally effect has no invented ordinary Karama response. Native Moritani allied retention remains a different, cancelable ability.',
'With actual Advanced Stronghold Cards, Arrakeen bank support changes the real payer debit, not personal income. Emperor Cunning temporary Sardaukar remain physically ordinary counters and retain ordinary Tanks custody. Original losing Moritani retention precedes mandatory original-winner Tech, then CHOAM inspection excludes used opposing cards even if retained. Ordinary city Collection remains separate; none of these windows repeats the original support, card cost or casualties.',
'Ecaz Betrayal is available to an unallied holder with Ecaz seated and reciprocally allied in a shared Arrakis territory. At the opening of Shipment and Movement, choose one such territory; the ally returns its entire normal and starred force group from every sector there to reserves, leaving Ecaz forces in place. The Nexus card is spent before a Karama response. Cancellation preserves the board but not the card. Only the base/Ecaz Nexus roster without combined modules is supported.',
'Ecaz Cunning lets an unallied native holder spend the physical Nexus card at a quiet Battle boundary to take the existing living Duke Vidal for the turn, even from Moritani. The Duke can be selected through ordinary Battle plans and is set aside after use or at turn end. This first version is limited to paired Ecaz/Moritani with other classic factions and no combined optional modules; Advanced Harkonnen is excluded because the existing battle path cannot select Duke there. Taking Duke from the Tanks, capture or Ghola custody remains unimplemented. The Battle-only timing is a product boundary, not printed text.',
'Fremen Betrayal has two bounded advance-declaration alternatives for an unallied holder in a classic Nexus table. Before the first Spice Blow or worm appearance, spend the physical card to prevent Fremen ordinary and Cunning worm rides for the turn; worm survival, destruction and Nexus still resolve. This clean timing is a product boundary, not an official post-appearance priority ruling. Instead, before Fremen move during Shipment and Movement, spend it to suppress their native two-territory advantage for that turn while ordinary movement and independent ornithopters remain. Only one alternative can be used even if the card is recycled later that turn. Owner controls show the currently available choice; combined modules and reactive post-worm timing remain gated.',
@@ -4403,6 +4405,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Richese Betrayal has a separate opt-in auction preview. Before payment, an unallied rival may spend its physical Richese Nexus card to discard a public self-cache win without any purchase debit, or redirect another buyer’s cache/Black Market payment to the bank while preserving delivery and earned bonus. Every publicly possible holder acknowledges the same opportunity; private Use eligibility cannot determine public timing. Normal hidden self-purchases and private special-Karama acquisitions are not connected. See the dedicated guide for its existing local runtime/browser evidence and remaining release gates; that evidence does not verify Nexus Kull.',
'Guild Cunning is declared when finishing your ordinary Shipment and Movement turn. It spends the Nexus and opens one Karama response. Allowance grants a second native shipment at normal Guild prices, including supported cross-shipment, return and Homeworld routes. Cancellation leaves the completed first turn intact. Only an unused Hajr move can follow the second shipment; no ordinary movement or third move is granted. The physical Ornithopter card combination remains unavailable. The separate guild-betrayal profile admits only original native shipment producers and explicitly blocks this second-shipment offer before Nexus spending; Cunning outside that profile is unchanged.',
'With Guild absent, its Secret Ally is an explicit optional source for one ordinary shipment at Guild prices. It supports physical reserve shipment, cross-shipment and return to abstract reserves, plus half-price independently legal world-to-world shipment. Fremen may retain free reinforcement or choose the paid card route beyond its normal range, outside the storm. Native faction, typed sources and normal movement stay unchanged. Homeworld reserve return awaits a ruling; arbitrary foreign-world transport and concealed No-Fields are not granted.',
'Borrowed Guild/Richese tariffs do not change native faction or physical reserve origin. Non-Guild/non-Fremen off-planet arrivals can accrue Heighliners once for collection at the original phase end. A paid borrowed Guild route from Fremen southern reserves is still on-planet and does not trigger it; stopped and native-Guild-only shipments likewise remain excluded. The existing Emperor bank-auction purchase retains the actual buyer wallet and creates no industry activity.',
'Guild Betrayal has a separate fresh classic/native-Guild development preview. Before a fully funded positive shipment settles, an unallied holder may spend the physical Guild Nexus to take the full original fee, including their own pre-funded payment, instead of Guild, bank or occupied Junction income. Original donor escrow is consumed once and original physical delivery is unchanged. Neutral public acknowledgements reveal no fee/source; only private eligibility exposes Use. Zero/stopped shipments create no award or Nexus cost. See the dedicated guide; new verification remains Planned and ordinary Homeworld occupation/rounding questions stay local.',
'Cunning spends its Nexus card and uses the native Mentat placement opportunity. A Karama response precedes placement; cancellation leaves the token where it was and the Nexus spent. Later Terror entry still uses the existing one-token stack selection and supported effect rules. If Extortion was revealed this turn, its bank award and payment choices follow that placement response; this does not implement Atomics or complete Moritani games.',
'Hidden reactive Betrayal timing remains unresolved for other effects. Complete module games are unfinished, so public module starts remain disabled.',
+259
View File
@@ -0,0 +1,259 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeNexusGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game,
} from '../game/engine';
import { territory } from '../game/board';
import { type Card } from '../game/cards';
import { validateNexusCards, nexusCardMode } from '../game/nexus-cards';
import { ownedTech } from '../game/tech-tokens';
import { quoteBattleResolution, type ResolutionCombatant } from '../game/battle-resolution-quote';
import { nextStrongholdFactionsNativeStep, type StrongholdFactionsNativeStep } from './fixture-stronghold-factions';
import { nexusInventory, orderNexusSpice } from './fixture-nexus-cards';
export type NexusModuleBattleOptions = {
initial?: Game;
family?: 'emperor' | 'retention';
tech?: boolean;
strongholds?: boolean;
advanced?: boolean;
};
export type NexusModuleBattleFixture = {
initial: Game;
afterSetup: Game;
beforeFirstMentat: Game;
firstMentatStep: StrongholdFactionsNativeStep;
afterFirstMentat: Game;
beforeBattle: Game;
battleStep: StrongholdFactionsNativeStep;
game: Game;
owner: string;
opponent: string;
observer: string;
location: string;
family: 'emperor' | 'retention';
played: Card;
unused: Card;
defense: Card;
planActions: StrongholdFactionsNativeStep[];
actions: StrongholdFactionsNativeStep[];
staging: string[];
};
export const nexusModuleBattlePlayer = (game: Game, actor: string) => {
const player = game.players.find(p => p.id === actor);
assert.ok(player, `Missing classic Nexus seat ${actor}`);
return player;
};
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
/** Extend the original native continuation only for actual Nexus aftermath.
* Unsealed private plans remain a human boundary, not bot-invented fixture state. */
export function nextNexusModuleBattleStep(game: Game): StrongholdFactionsNativeStep | null {
if (!game.phaseOpening && !game.response) {
const d = game.decision;
if (d?.kind === 'wormRide')
return { actor: d.player, action: { type: 'decision', accept: false } };
if (d?.kind === 'nexusFremenCunningOffer' || d?.kind === 'nexusFremenCunningRide')
return { actor: d.player, action: { type: 'decision', event: d.event, accept: false } };
if (d?.kind === 'moritaniRetention' && d.source === 'nexus')
return { actor: d.player, action: { type: 'decision', event: d.event, keep: null } };
if (d?.kind === 'nexusChoamInspection')
return { actor: d.player, action: { type: 'decision', event: d.event, inspect: false } };
if (d?.kind === 'techToken')
return { actor: d.player, action: { type: 'decision', token: d.choices[0] } };
}
return nextStrongholdFactionsNativeStep(game);
}
function entropy<T>(fn: () => T): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
assert.ok(array);
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255);
return array;
};
try { return fn(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
function run(game: Game, step: StrongholdFactionsNativeStep, actions: StrongholdFactionsNativeStep[]): Game {
actions.push(structuredClone(step));
return entropy(() => applyAction(game, step.actor, step.action));
}
function advance(game: Game, until: (state: Game) => boolean, actions: StrongholdFactionsNativeStep[]): Game {
for (let n = 0; n < 1800; n++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const step = nextNexusModuleBattleStep(game);
assert.ok(step, `Human plan boundary before requested continuation at ${game.turn}/${game.phase}`);
// Real dials, not a staged Storm position; both printed sites survive.
if (step.action.type === 'stormDial' && game.turn === 1) step.action.amount = 0;
game = run(game, step, actions);
}
throw Error('Classic Nexus battle continuation did not reach its boundary');
}
function reserveBoard(game: Game): void {
for (const player of game.players) {
player.reserves += Object.values(player.forces).reduce((sum, count) => sum + count, 0);
player.forces = {};
if (player.elites) {
player.elites.reserves += Object.values(player.elites.forces).reduce((sum, count) => sum + count, 0);
player.elites.forces = {};
}
}
}
function place(game: Game, actor: string, location: string, count: number): void {
const player = nexusModuleBattlePlayer(game, actor);
assert.ok(player.reserves - (player.elites?.reserves ?? 0) >= count);
player.reserves -= count;
player.forces[location] = count;
}
function hold(game: Game, actor: string, kind: Card['kind']): Card {
const index = game.deck.findIndex(card => card.kind === kind);
assert.ok(index >= 0, `Missing canonical ${kind}`);
const card = game.deck.splice(index, 1)[0];
nexusModuleBattlePlayer(game, actor).hand.push(card);
return card;
}
/** Genuine all-twelve setup, actual turn-two worm, reciprocal observer alliance
* and applyAction closing Nexus deals. Conserved physical deck order is explicit.
* Phases, wallets, first-Storm Tech and first END Mentat custody are never staged. */
export function createNexusModuleBattleFixture(options: NexusModuleBattleOptions = {}): NexusModuleBattleFixture {
const family = options.family ?? 'retention', tech = options.tech ?? true;
const strongholds = options.strongholds ?? true, advanced = options.advanced ?? true;
assert.ok(tech || strongholds);
if (strongholds || family === 'emperor') assert.equal(advanced, true);
const actions: StrongholdFactionsNativeStep[] = [], staging: string[] = [];
let game = options.initial ? structuredClone(options.initial)
: createGame('NEXUSMODULEBATTLE', newPlayer('emperor', 'Emperor', 'emperor'), advanced, []);
if (!options.initial) {
joinGame(game, newPlayer('guild', 'Guild', 'guild'));
joinGame(game, newPlayer('atreides', 'Atreides', 'atreides'));
joinGame(game, newPlayer('fremen', 'Fremen', 'fremen'));
}
assert.deepEqual(game.players.map(p => p.faction).sort(), ['atreides', 'emperor', 'fremen', 'guild']);
assert.deepEqual(game.expansions, []);
assert.equal(game.advanced, advanced);
assert.ok(game.status === 'lobby' || game.status === 'setup' && game.turn === 1 && game.phase === 0 && game.players.every(p => !p.hand.length));
if (game.status === 'lobby') {
// Offline audit configuration only; no public Nexus start/toggle is added.
if (!game.nexusCards) game.nexusCards = { cards: null, phase: null };
if (!!game.techTokens !== tech) game = run(game, { actor: game.host, action: { type: 'techTokens', enabled: tech } }, actions);
if (!!game.strongholdCards !== strongholds) game = run(game, { actor: game.host, action: { type: 'strongholdCards', enabled: strongholds } }, actions);
for (const player of game.players) if (!player.ready) game = run(game, { actor: player.id, action: { type: 'ready' } }, actions);
game = entropy(() => initializeNexusGameForAudit(game));
}
assert.equal(!!game.techTokens, tech);
assert.equal(!!game.strongholdCards, strongholds);
const initial = structuredClone(game);
game = advance(game, state => state.status === 'playing', actions);
const afterSetup = structuredClone(game);
const owner = game.players.find(p => p.faction === 'emperor')!.id;
const opponent = game.players.find(p => p.faction === 'guild')!.id;
const observer = game.players.find(p => p.faction === 'atreides')!.id;
const partner = game.players.find(p => p.faction === 'fremen')!.id;
const location = `arrakeen:${territory('arrakeen').sectors[0]}`;
validateNexusCards(game.nexusCards!.cards!, game.players);
game = advance(game, state => state.phase === 1 && clean(state), actions);
orderNexusSpice(game, ['land', 'land']);
game = advance(game, state => state.phase === 5 && clean(state), actions);
reserveBoard(game);
game = advance(game, state => state.phase === 8 && clean(state), actions);
place(game, owner, location, 1);
staging.push('Before real first END Mentat, conserved one ordinary Emperor counter controls Arrakeen; subtype reserves, real banks and first-Storm Tech retained.');
let beforeFirstMentat!: Game, firstMentatStep!: StrongholdFactionsNativeStep;
while (game.turn === 1) {
const step = nextNexusModuleBattleStep(game); assert.ok(step);
const before = structuredClone(game);
game = run(game, step, actions);
if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = step; }
}
const afterFirstMentat = structuredClone(game);
if (strongholds) assert.equal(game.strongholdCards!.owners.arrakeen, owner);
game = advance(game, state => state.phase === 1 && clean(state), actions);
orderNexusSpice(game, ['worm', 'land', 'land']);
game = advance(game, state => state.nexus === true && !state.spiceWindow, actions);
game = run(game, { actor: observer, action: { type: 'alliance', target: partner } }, actions);
game = run(game, { actor: partner, action: { type: 'alliance', target: observer } }, actions);
game = advance(game, state => state.nexusCards?.phase?.stage === 'drawing', actions);
const deals = [
{ actor: owner, card: family === 'emperor' ? 'emperor' as const : 'choam' as const },
{ actor: opponent, card: 'moritani' as const },
];
for (const deal of deals) {
const cards = game.nexusCards!.cards!;
assert.ok(cards.deck.includes(deal.card));
cards.deck = [deal.card, ...cards.deck.filter(card => card !== deal.card)];
game = run(game, { actor: deal.actor, action: {
type: 'nexusCardChoice', turn: game.turn, card: null, choice: 'draw', ownRedraws: 0,
} }, actions);
}
assert.equal(nexusCardMode(family === 'emperor' ? 'emperor' : 'choam', 'emperor', game.players.map(p => p.faction)), family === 'emperor' ? 'cunning' : 'secretAlly');
assert.equal(nexusCardMode('moritani', 'guild', game.players.map(p => p.faction)), 'secretAlly');
staging.push('Conserved all12 Nexus deck order; actual turn-two worm and Atreides/Fremen alliance open original applyAction closing deals for unallied Emperor/Guild.');
game = advance(game, state => state.phase === 5 && clean(state), actions);
reserveBoard(game);
// Return genuine setup hands before selecting canonical controlled battle cards.
for (const player of game.players) game.deck.push(...player.hand.splice(0));
place(game, owner, location, 7);
place(game, opponent, location, 6);
const played = hold(game, opponent, 'projectile');
const unused = hold(game, opponent, 'worthless');
const defense = hold(game, owner, 'shield');
staging.push('Turn-two movement: conserved seven ordinary Emperor and six Guild board counters; original projectile/worthless/shield cards relocated from physical deck, no bank/owner/phase writes.');
if (tech) assert.equal(ownedTech(game.techTokens, opponent).length, 1);
game = advance(game, state => state.phase === 6 && clean(state), actions);
const beforeBattle = structuredClone(game);
const battleStep: StrongholdFactionsNativeStep = { actor: game.active!, action: { type: 'chooseBattle', territory: 'arrakeen', target: game.active === owner ? opponent : owner } };
assert.ok([owner, opponent].includes(battleStep.actor));
game = run(game, battleStep, actions);
game = advance(game, state => clean(state) && !!state.battle && (!state.battle.preLeader || state.battle.preLeader.closed) && !state.battle.preparation, actions);
const best = nexusModuleBattlePlayer(game, owner).leaders.filter(l => !l.dead).sort((a, b) => b.strength - a.strength)[0];
const weakest = nexusModuleBattlePlayer(game, opponent).leaders.filter(l => !l.dead).sort((a, b) => a.strength - b.strength)[0];
assert.ok(best && weakest);
const planActions: StrongholdFactionsNativeStep[] = [
{ actor: owner, action: { type: 'battlePlan', leader: best.id, dial: 6, support: advanced ? 6 : 0, defense: defense.id } },
{ actor: opponent, action: { type: 'battlePlan', leader: weakest.id, dial: 0, support: 0, weapon: played.id } },
];
nexusInventory(game);
return { initial, afterSetup, beforeFirstMentat, firstMentatStep, afterFirstMentat, beforeBattle, battleStep, game, owner, opponent, observer, location, family, played, unused, defense, planActions, actions, staging };
}
export function revealNexusModuleBattle(fixture: NexusModuleBattleFixture, game = fixture.game, cunning = false): Game {
if (cunning) {
const offer = viewGame(game, fixture.owner).nexusSardaukar!.offer!;
assert.equal(offer.blocked, null);
game = applyAction(game, fixture.owner, { type: 'nexusSardaukar', event: offer.event });
game = advance(game, clean, []);
}
for (const step of fixture.planActions) {
const action: Action = { ...step.action };
if (cunning && step.actor === fixture.owner) action.support = 3;
game = applyAction(game, step.actor, action);
}
assert.ok(game.battle?.revealed);
return game;
}
/** Quote actual holders, committed plans and physical ordinary/elite pools. */
export function quoteNexusModuleBattle(game: Game) {
const battle = game.battle; assert.ok(battle?.revealed);
const side = (actor: string): ResolutionCombatant => {
const player = nexusModuleBattlePlayer(game, actor), view = viewGame(game, actor).battle!;
assert.ok(view.ownForces);
return { id: actor, faction: player.faction, spice: player.spice, hand: player.hand,
plan: battle.plans[actor], leader: player.leaders.find(l => l.id === battle.plans[actor].leader),
forces: view.ownForces, stronghold: view.strongholdEffects[actor] };
};
return quoteBattleResolution({ advanced: game.advanced, turn: game.turn, territory: battle.territory,
aggressor: viewGame(game, game.host).battle!.aggressor,
attacker: side(battle.attacker), defender: side(battle.defender),
voters: viewGame(game, game.host).battle!.traitorVoters.map(actor => ({ id: actor, beneficiary: actor,
called: battle.traitorCalls[actor] ?? false, traitors: nexusModuleBattlePlayer(game, actor).traitors })),
participants: game.players, physicalCards: [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand)],
pendingAuditorPresent: !!game.pendingAuditor, pendingRetentionPresent: !!game.moritaniRetention,
nexusMoritani: true });
}
export function advanceNexusModuleBattle(game: Game, until: (state: Game) => boolean): Game {
return advance(game, until, []);
}
+264
View File
@@ -0,0 +1,264 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeNexusGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game,
} from '../game/engine';
import { territory } from '../game/board';
import type { FactionId } from '../game/catalog';
import { quoteNexusGuildSecretShipment } from '../game/nexus-guild-secret-ally';
import { quoteNexusRicheseShipment } from '../game/nexus-richese';
import { ownedTech, type TechId } from '../game/tech-tokens';
import { classicNexusModulesProfile } from '../game/nexus-module-profile';
import { nextSpiceBankerIncomeNativeStep } from './fixture-spice-banker-income';
import { nexusInventory, orderNexusSpice } from './fixture-nexus-cards';
import { skillsTechPaymentsEmptyDestination } from './fixture-skills-tech-payments';
export type NexusModulePaymentKind = 'guild-shipment' | 'richese-shipment' | 'emperor-revival' | 'fremen-revival' | 'emperor-purchase';
export type NexusModulePaymentStep = { actor: string; action: Action };
export type NexusModulePaymentsOptions = {
initial?: Game;
kind?: NexusModulePaymentKind;
advanced?: boolean;
tech?: boolean;
strongholds?: boolean;
ownerFaction?: 'atreides' | 'harkonnen' | 'emperor' | 'fremen' | 'guild';
nativeGuild?: boolean;
tanks?: number;
eliteTanks?: number;
};
export type NexusModulePaymentsFixture = {
game: Game;
beforePayment: Game;
setup: Game;
actions: NexusModulePaymentStep[];
staging: string[];
actor: string;
kind: NexusModulePaymentKind;
paymentAction: Action;
amount: number;
cost: number;
token: TechId;
tokenOwner: string | null;
tokenCount: number;
destination?: string;
};
/** Use the existing original continuation policy, never a fixture phase jump. */
export function nextNexusModulePaymentsNativeStep(game: Game): NexusModulePaymentStep | null {
if (game.nexusCards?.phase?.stage === 'drawing' && !game.response && !game.decision) {
const phase = game.nexusCards.phase;
const actor = phase.eligible.find(id => !phase.done.includes(id));
if (actor) return { actor, action: { type: 'nexusCardChoice', turn: game.turn,
card: game.nexusCards.cards!.hands[actor], choice: 'keep', ownRedraws: 0 } };
}
return nextSpiceBankerIncomeNativeStep(game);
}
function step(game: Game, actions?: NexusModulePaymentStep[]): Game {
const next = nextNexusModulePaymentsNativeStep(game);
assert.ok(next, 'The original Nexus payment continuation needs an action.');
actions?.push(structuredClone(next));
return applyAction(game, next.actor, next.action);
}
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
export function advanceNexusModulePaymentsToPhase(state: Game, phase: number,
actions?: NexusModulePaymentStep[]): Game {
let game = state;
const turn = game.turn;
for (let n = 0; (game.phase < phase || !clean(game)) && n < 1200; n++) {
assert.equal(game.turn, turn);
game = step(game, actions);
}
assert.equal(game.turn, turn);
assert.ok(game.phase >= phase && clean(game));
return game;
}
function deterministic<T>(run: () => T): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
assert.ok(array);
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255);
return array;
};
try { return run(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
function reserveBoard(game: Game, staging: string[]) {
for (const player of game.players) {
const count = Object.values(player.forces).reduce((a, b) => a + b, 0);
player.reserves += count;
player.forces = {};
if (player.elites) {
player.elites.reserves += Object.values(player.elites.forces).reduce((a, b) => a + b, 0);
player.elites.forces = {};
}
if (player.advisors) player.advisors = {};
if (count) staging.push(`Conserved ${player.id}: ${count} board counters to reserves.`);
}
}
/** Fresh classic lobby and original setup/Storm/first-Mentat ownership. No
* manufactured wallet, phase, Tech owner, Stronghold owner or earned receipt. */
export function initializeNexusModulePaymentsSetup(options: NexusModulePaymentsOptions = {}): Game {
const kind = options.kind ?? 'guild-shipment';
const ownerFaction = options.ownerFaction ?? (kind === 'fremen-revival' ? 'emperor' : 'atreides');
let game = options.initial ? structuredClone(options.initial)
: createGame('NEXUSMODULEPAYMENTS', newPlayer('payer', 'Payer', ownerFaction),
options.advanced ?? !!options.strongholds);
if (game.status === 'setup') {
assert.equal(game.turn, 1); assert.equal(game.phase, 0);
assert.ok(game.players.every(p => p.hand.length === 0));
assert.ok(classicNexusModulesProfile(game));
assert.equal(!!game.techTokens, options.tech !== false);
assert.equal(!!game.strongholdCards, !!options.strongholds);
if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced);
assert.ok(game.players.some(p => p.faction === ownerFaction));
return game;
}
assert.equal(game.status, 'lobby', 'Use the fresh authenticated undealt lobby.');
if (!options.initial) {
const forbidden = kind === 'guild-shipment' ? 'guild' : kind === 'fremen-revival' ? 'fremen'
: kind.startsWith('emperor-') ? 'emperor' : 'richese';
const candidates: FactionId[] = options.nativeGuild ? ['guild', 'harkonnen', 'atreides', 'emperor']
: ['harkonnen', 'atreides', 'emperor', 'guild'];
for (const faction of candidates) if (game.players.length < 3 && faction !== forbidden && faction !== ownerFaction)
joinGame(game, newPlayer(faction, faction, faction));
}
assert.ok(game.players.some(p => p.faction === ownerFaction));
if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced);
if (options.tech !== false && !game.techTokens)
game = applyAction(game, game.host, { type: 'techTokens', enabled: true });
if (options.strongholds && !game.strongholdCards)
game = applyAction(game, game.host, { type: 'strongholdCards', enabled: true });
if (!game.nexusCards) game.nexusCards = { cards: null, phase: null };
for (const player of game.players) if (!player.ready)
game = applyAction(game, player.id, { type: 'ready' });
return deterministic(() => initializeNexusGameForAudit(game));
}
/** Native second-turn Nexus supplies the singleton through an actual draw.
* Only conserved board/card ordering and reserve-to-Tanks casualties are staged.
* Stops before the real human payment/spend; initial preserves authenticated IDs. */
export function createNexusModulePaymentsFixture(options: NexusModulePaymentsOptions = {}): NexusModulePaymentsFixture {
return deterministic(() => {
const actions: NexusModulePaymentStep[] = [], staging: string[] = [];
const kind = options.kind ?? 'guild-shipment';
const ownerFaction = options.ownerFaction ?? (kind === 'fremen-revival' ? 'emperor' : 'atreides');
const setup = initializeNexusModulePaymentsSetup(options);
let game = setup;
for (let n = 0; game.status === 'setup' && n < 200; n++) game = step(game, actions);
assert.equal(game.status, 'playing');
const actor = game.players.find(p => p.faction === ownerFaction)!.id;
reserveBoard(game, staging);
if (options.strongholds) {
const player = game.players.find(p => p.id === actor)!;
player.reserves--;
player.forces['arrakeen:10'] = 1;
staging.push(`Conserved ${actor}: one reserve counter to Arrakeen before genuine first Mentat claim.`);
}
// A real full first turn precedes a real second-turn worm/Nexus. First-turn
// land-only ordering avoids ignored drawn worms, without adding spice cards.
let firstOrdered = false;
for (let n = 0; !(game.turn === 2 && game.phase === 1 && clean(game)) && n < 1400; n++) {
if (game.turn === 1 && game.phase === 1 && !firstOrdered) {
orderNexusSpice(game, ['land', 'land']); firstOrdered = true;
}
game = step(game, actions);
}
assert.equal(game.turn, 2);
assert.equal(game.phase, 1);
if (options.strongholds) assert.equal(game.strongholdCards!.owners.arrakeen, actor);
reserveBoard(game, staging);
orderNexusSpice(game, ['worm', 'land', 'land']);
let allied = false;
for (let n = 0; game.nexusCards?.phase?.stage !== 'drawing' && n < 200; n++) {
if (game.nexus && !game.spiceWindow && !game.spiceResolution && clean(game) && !allied) {
const others = game.players.filter(p => p.id !== actor);
assert.ok(others.length >= 2);
for (const [from, to] of [[others[0].id, others[1].id], [others[1].id, others[0].id]]) {
const action: Action = { type: 'alliance', target: to };
actions.push({ actor: from, action });
game = applyAction(game, from, action);
}
allied = true;
}
game = step(game, actions);
}
assert.ok(allied, 'The real Nexus negotiations must precede the singleton draw.');
assert.equal(game.nexusCards?.phase?.stage, 'drawing');
const card: FactionId = kind === 'guild-shipment' ? 'guild' : kind === 'richese-shipment' ? 'richese'
: kind.startsWith('emperor-') ? 'emperor' : 'fremen';
const cards = game.nexusCards!.cards!;
assert.ok(cards.deck.includes(card));
cards.deck = [card, ...cards.deck.filter(c => c !== card)];
const draw: NexusModulePaymentStep = { actor, action: { type: 'nexusCardChoice', turn: game.turn,
card: cards.hands[actor], choice: 'draw', ownRedraws: 0 } };
actions.push(draw);
game = applyAction(game, actor, draw.action);
assert.equal(game.nexusCards!.cards!.hands[actor], card);
while (game.nexusCards?.phase?.stage === 'drawing') game = step(game, actions);
const shipment = kind.endsWith('shipment');
const purchase = kind === 'emperor-purchase';
game = advanceNexusModulePaymentsToPhase(game, shipment ? 5 : purchase ? 3 : 4, actions);
let paymentAction: Action, cost = 0, destination: string | undefined;
const amount = shipment ? 5 : purchase ? 1 : 3;
if (purchase) {
for (let n = 0; game.decision?.kind !== 'auctionPayment' && n < 100; n++) {
if (game.auction?.active === actor && !game.auction.bid) {
const action: Action = { type: 'bid', amount: 2 };
actions.push({ actor, action });
game = applyAction(game, actor, action);
} else game = step(game, actions);
}
assert.equal(game.phase, 3);
assert.equal(game.decision?.kind, 'auctionPayment');
assert.equal(game.decision!.player, actor);
assert.equal(game.auction!.bidder, actor);
assert.equal(game.auction!.bid, 2);
assert.equal(game.auction!.allyPayment ?? 0, 0);
cost = game.auction!.bid;
paymentAction = { type: 'nexusEmperorPurchase', event: viewGame(game, actor).nexusEmperorSecretAlly!.event };
} else if (shipment) {
for (let n = 0; game.active !== actor && n < 30; n++) game = step(game, actions);
assert.equal(game.active, actor);
const target = skillsTechPaymentsEmptyDestination(game);
destination = `${target.territory}:${target.sector}`;
const player = game.players.find(p => p.id === actor)!;
cost = kind === 'guild-shipment' ? quoteNexusGuildSecretShipment(territory(target.territory).type, amount).cost
: quoteNexusRicheseShipment({ faction: player.faction, halfRate: player.faction === 'guild' },
territory(target.territory).type, amount).cost;
const view = viewGame(game, actor);
const event = kind === 'guild-shipment' ? view.nexusGuildSecretAlly!.event : view.nexusRichese!.event;
paymentAction = { type: 'ship', ...target, amount, nexus: event };
} else {
const player = game.players.find(p => p.id === actor)!;
const tanks = options.tanks ?? 6, elite = options.eliteTanks ?? 0;
assert.ok(tanks <= player.reserves && elite <= (player.elites?.reserves ?? 0));
player.reserves -= tanks; player.tanks += tanks;
if (player.elites) { player.elites.reserves -= elite; player.elites.tanks += elite; }
staging.push(`Conserved ${actor}: ${tanks} reserves to Tanks, including ${elite} actual elites.`);
const view = viewGame(game, actor);
const event = kind === 'emperor-revival' ? view.nexusEmperorSecretAlly!.event : view.nexusFremenRevival!.event;
paymentAction = { type: kind === 'emperor-revival' ? 'nexusEmperorRevive' : 'nexusFremenRevive',
event, elite: elite ? 1 : 0 };
}
nexusInventory(game);
const token: TechId = shipment ? 'heighliners' : purchase ? 'production' : 'axlotl';
const tokenOwner = game.techTokens?.[token].owner ?? null;
const tokenCount = tokenOwner ? ownedTech(game.techTokens, tokenOwner).length : 0;
return { game, beforePayment: structuredClone(game), setup, actions, staging, actor, kind,
paymentAction, amount, cost, token, tokenOwner, tokenCount, destination };
});
}
export function payNexusModulePaymentsFixture(fixture: NexusModulePaymentsFixture): Game {
let game = applyAction(fixture.game, fixture.actor, fixture.paymentAction);
for (let n = 0; (!clean(game) || game.pendingShipment || game.pendingRevival) && n < 200; n++)
game = step(game);
assert.ok(clean(game) && !game.pendingShipment && !game.pendingRevival);
return game;
}
+180
View File
@@ -0,0 +1,180 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, normalizeAutomaticGame, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { ownedTech } from '../game/tech-tokens';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
import { nexusInventory } from './fixture-nexus-cards';
import {
createNexusModuleBattleFixture, revealNexusModuleBattle, quoteNexusModuleBattle,
advanceNexusModuleBattle, nexusModuleBattlePlayer,
} from './fixture-nexus-module-battles';
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game;
for (const modules of [
{ label: 'Basic Tech-only', advanced: false, tech: true, strongholds: false },
{ label: 'Advanced Tech + actual Arrakeen card', advanced: true, tech: true, strongholds: true },
]) void test(`${modules.label}: physical Moritani retention precedes winner Tech, then CHOAM inspects only the unused card`, () => {
const fixture = createNexusModuleBattleFixture({ ...modules, family: 'retention' });
const revealed = revealNexusModuleBattle(fixture);
const quote = quoteNexusModuleBattle(revealed);
assert.equal(quote.winner, fixture.owner);
assert.deepEqual(quote.destroyedArmies, [fixture.opponent]);
const payment = quote.payments.find(p => p.player === fixture.owner);
if (modules.advanced) {
assert.ok(payment);
assert.equal(payment.cost, 4);
assert.equal(payment.bankSupport, 2);
assert.equal(payment.allyPayment, 0);
} else assert.deepEqual(quote.payments, [], 'Basic force commitments consume no spice support.');
assert.equal(quote.retention?.source, 'nexus');
const token = ownedTech(revealed.techTokens, fixture.opponent)[0];
assert.ok(token);
const bank = nexusModuleBattlePlayer(revealed, fixture.owner).spice;
const guildTanks = nexusModuleBattlePlayer(revealed, fixture.opponent).tanks;
const emperorTanks = nexusModuleBattlePlayer(revealed, fixture.owner).tanks;
let game = advanceNexusModuleBattle(revealed, state => state.decision?.kind === 'moritaniRetention');
assert.equal(nexusModuleBattlePlayer(game, fixture.owner).spice, bank - (payment?.ownPayment ?? 0));
assert.equal(nexusModuleBattlePlayer(game, fixture.opponent).tanks, guildTanks + 6);
assert.equal(nexusModuleBattlePlayer(game, fixture.owner).tanks, emperorTanks + 6);
assert.equal(game.techTokens![token].owner, fixture.opponent);
assert.equal(game.lastBattleContext?.nexusChoamInspection?.stage, 'pending');
assert.equal(game.lastBattleContext?.nexusMoritaniRetention?.stage, 'offer');
assert.equal(viewGame(game, fixture.opponent).nexusMoritaniRetention?.canKeep, true);
const event = game.lastBattleContext!.event;
const before = reload(game);
assert.throws(() => applyAction(game, fixture.owner, { type: 'decision', event, keep: fixture.played.id }));
assert.throws(() => applyAction(game, fixture.opponent, { type: 'decision', event, keep: fixture.unused.id }));
assert.throws(() => applyAction(game, fixture.owner, { type: 'decision', event, inspect: true }));
assert.deepEqual(game, before);
const keep = { type: 'decision', event, keep: fixture.played.id };
game = applyAction(game, fixture.opponent, keep);
// Single original token transfer occurs inside the retention continuation.
assert.equal(game.techTokens![token].owner, fixture.owner);
assert.equal(game.decision?.kind, 'nexusChoamInspection');
assert.equal(game.lastBattleContext?.nexusMoritaniRetention?.kept, fixture.played.id);
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'moritani').length, 1);
assert.ok(nexusModuleBattlePlayer(game, fixture.opponent).hand.some(card => card.id === fixture.played.id));
assert.equal(game.discard.some(card => card.id === fixture.played.id), false);
assert.throws(() => applyAction(game, fixture.opponent, keep));
const inspect = { type: 'decision', event, inspect: true };
assert.throws(() => applyAction(game, fixture.owner, { ...inspect, card: fixture.played.id }));
game = applyAction(game, fixture.owner, inspect);
assert.equal(game.nexusChoamInsight?.card.id, fixture.unused.id);
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'choam').length, 1);
assert.ok(nexusModuleBattlePlayer(game, fixture.opponent).hand.some(card => card.id === fixture.played.id));
assert.ok(nexusModuleBattlePlayer(game, fixture.opponent).hand.some(card => card.id === fixture.unused.id));
assert.ok(nexusModuleBattlePlayer(game, fixture.owner).hand.some(card => card.id === fixture.defense.id));
assert.equal(game.lastBattleContext?.nexusChoamInspection?.stage, 'complete');
assert.equal(game.phase, 7);
assert.throws(() => applyAction(game, fixture.owner, inspect));
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
nexusInventory(game);
});
void test('Strongholds-only all-pass keeps Nexus cards, discards the original losing weapon once and preserves actual Arrakeen support', () => {
const fixture = createNexusModuleBattleFixture({ tech: false, strongholds: true });
const revealed = revealNexusModuleBattle(fixture);
const quote = quoteNexusModuleBattle(revealed);
const balance = nexusModuleBattlePlayer(revealed, fixture.owner).spice;
let game = advanceNexusModuleBattle(revealed, state => state.decision?.kind === 'moritaniRetention');
const event = game.lastBattleContext!.event;
game = applyAction(game, fixture.opponent, { type: 'decision', event, keep: null });
assert.equal(game.decision?.kind, 'nexusChoamInspection');
assert.equal(game.discard.filter(card => card.id === fixture.played.id).length, 1);
assert.deepEqual(nexusModuleBattlePlayer(game, fixture.opponent).hand.map(card => card.id), [fixture.unused.id]);
const collection = quoteSpiceCollection(game).receipts.find(p => p.player === fixture.owner)!;
game = applyAction(game, fixture.owner, { type: 'decision', event, inspect: false });
assert.equal(game.nexusCards!.cards!.hands[fixture.owner], 'choam');
assert.equal(game.nexusCards!.cards!.hands[fixture.opponent], 'moritani');
assert.equal(nexusModuleBattlePlayer(game, fixture.owner).spice,
balance - quote.payments.find(p => p.player === fixture.owner)!.ownPayment +
collection.strongholds + collection.collected);
assert.equal(nexusModuleBattlePlayer(game, fixture.owner).forces[fixture.location], 1);
assert.equal(game.strongholdCards!.owners.arrakeen, fixture.owner);
assert.equal(game.phase, 7);
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
nexusInventory(game);
});
void test('Tech-only Emperor passing Cunning retains its Nexus and pays ordinary support before losing ordinary counters', () => {
const fixture = createNexusModuleBattleFixture({ family: 'emperor', strongholds: false });
const revealed = revealNexusModuleBattle(fixture);
const quote = quoteNexusModuleBattle(revealed);
const payment = quote.payments.find(p => p.player === fixture.owner)!;
assert.equal(payment.ownPayment, 6);
assert.equal(payment.bankSupport, 0);
assert.deepEqual(quote.casualties!.options, [{ normal: 6, elite: 0, paidNormal: 6, paidElite: 0 }]);
const balance = nexusModuleBattlePlayer(revealed, fixture.owner).spice;
const elites = structuredClone(nexusModuleBattlePlayer(revealed, fixture.owner).elites);
const settled = advanceNexusModuleBattle(revealed, state => state.decision?.kind === 'nexusChoamInspection');
const collection = quoteSpiceCollection(settled).receipts.find(p => p.player === fixture.owner)!;
const done = advanceNexusModuleBattle(settled, state => state.phase === 7);
assert.equal(done.nexusCards!.cards!.hands[fixture.owner], 'emperor');
assert.equal(done.nexusCards!.cards!.hands[fixture.opponent], 'moritani');
assert.equal(nexusModuleBattlePlayer(done, fixture.owner).forces[fixture.location], 1);
assert.equal(nexusModuleBattlePlayer(done, fixture.owner).spice,
balance - 6 + collection.strongholds + collection.collected);
assert.deepEqual(nexusModuleBattlePlayer(done, fixture.owner).elites, elites);
assert.equal(done.discard.filter(card => card.id === fixture.played.id).length, 1);
const token = ownedTech(revealed.techTokens, fixture.opponent)[0];
assert.equal(done.techTokens![token].owner, fixture.owner);
assert.deepEqual(normalizeAutomaticGame(reload(done)), done);
nexusInventory(done);
});
void test('Emperor Cunning counts ordinary forces temporarily, pays actual-holder Arrakeen support and loses no starred counters', () => {
const fixture = createNexusModuleBattleFixture({ family: 'emperor' });
const originalElites = structuredClone(nexusModuleBattlePlayer(fixture.game, fixture.owner).elites);
const revealed = revealNexusModuleBattle(fixture, fixture.game, true);
const quote = quoteNexusModuleBattle(revealed);
assert.equal(quote.winner, fixture.owner);
assert.deepEqual(quote.casualties!.forces, viewGame(revealed, fixture.owner).battle!.ownForces);
assert.equal(quote.casualties!.forces.temporaryElite, 5);
assert.equal(quote.casualties!.forces.normal, 7);
assert.equal(quote.casualties!.forces.elite, 0);
assert.deepEqual(quote.casualties!.options[0], { normal: 3, elite: 0, paidNormal: 3, paidElite: 0 });
const payment = quote.payments.find(p => p.player === fixture.owner)!;
assert.equal(payment.cost, 1);
assert.equal(payment.bankSupport, 2);
assert.equal(payment.ownPayment, 1);
assert.equal(payment.allyPayment, 0);
const bank = nexusModuleBattlePlayer(revealed, fixture.owner).spice;
const tanks = nexusModuleBattlePlayer(revealed, fixture.owner).tanks;
let game = advanceNexusModuleBattle(revealed, state => state.decision?.kind === 'moritaniRetention');
const event = game.lastBattleContext!.event;
assert.equal(nexusModuleBattlePlayer(game, fixture.owner).tanks, tanks + 3);
assert.equal(nexusModuleBattlePlayer(game, fixture.owner).forces[fixture.location], 4);
assert.deepEqual(nexusModuleBattlePlayer(game, fixture.owner).elites, originalElites);
assert.equal(nexusModuleBattlePlayer(game, fixture.owner).spice, bank - 1);
assert.equal(game.nexusSardaukarHistory?.length, 1);
assert.equal(game.nexusSardaukarHistory![0].casualties?.outcome, 'complete');
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'emperor').length, 1);
game = applyAction(game, fixture.opponent, { type: 'decision', event, keep: null });
assert.equal(viewGame(game, fixture.owner).nexusChoamInspection?.canInspect, false);
game = applyAction(game, fixture.owner, { type: 'decision', event, inspect: false });
assert.equal(game.phase, 7);
assert.deepEqual(nexusModuleBattlePlayer(game, fixture.owner).elites, originalElites);
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
assert.throws(() => applyAction(game, fixture.owner, { type: 'nexusSardaukar', event: game.nexusSardaukarHistory![0].receipt.event }));
nexusInventory(game);
});
void test('minimal legal bot continuation retains the played card, completes single-owner Tech and inspects the unused card', () => {
const fixture = createNexusModuleBattleFixture();
let game = advanceNexusModuleBattle(revealNexusModuleBattle(fixture), state => state.decision?.kind === 'moritaniRetention');
for (const actor of [fixture.opponent, fixture.owner]) {
const view = viewGame(game, actor);
view.players.find(p => p.id === actor)!.bot = 'Easy';
const action = botActions(view)[0];
assert.ok(action);
if (actor === fixture.opponent) assert.equal(action.keep, fixture.played.id);
else assert.equal(action.inspect, true);
game = applyAction(game, actor, action);
}
assert.equal(game.techTokens![ownedTech(fixture.game.techTokens, fixture.opponent)[0]].owner, fixture.owner);
assert.equal(game.nexusChoamInsight?.card.id, fixture.unused.id);
assert.equal(game.phase, 7);
nexusInventory(game);
});
+300
View File
@@ -0,0 +1,300 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, initializeNexusGameForAudit, joinGame, newPlayer, viewGame,
type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
import { forceRevivalQuote, forceRevivalRemaining } from '../game/revival';
import { createStrongholdCards } from '../game/stronghold-cards';
import { createLeaderSkills } from '../game/leader-skills';
import { createTechTokens } from '../game/tech-tokens';
import { nexusInventory } from './fixture-nexus-cards';
import { holdNexusRicheseCard } from './fixture-nexus-richese';
import {
advanceNexusModulePaymentsToPhase, createNexusModulePaymentsFixture,
nextNexusModulePaymentsNativeStep, payNexusModulePaymentsFixture,
type NexusModulePaymentsFixture,
} from './fixture-nexus-module-payments';
function wallets(game: Game): Record<string, number> {
return Object.fromEntries(game.players.map(player => [player.id, player.spice]));
}
function rejected(game: Game, actor: string, action: Parameters<typeof applyAction>[2]) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before, 'Rejected declarations spend neither physical card, counter nor spice.');
}
/** Empty Battle can auto-skip into Collection. Its independently quoted native
* stronghold income is not technology and cannot be mistaken for a second pile. */
function collectOnce(fixture: NexusModulePaymentsFixture, paid: Game, amount: number): Game {
const expected = wallets(paid);
let ended = advanceNexusModulePaymentsToPhase(paid, paid.phase + 1);
if (ended.phase === 7) {
for (const receipt of quoteSpiceCollection(paid).receipts) expected[receipt.player] = receipt.balance;
}
if (fixture.tokenOwner) expected[fixture.tokenOwner] += amount;
assert.deepEqual(wallets(ended), expected);
assert.equal(ended.techTokens![fixture.token].spice, 0);
const collection = advanceNexusModulePaymentsToPhase(ended, 7);
const beforeMentat = wallets(collection);
ended = advanceNexusModulePaymentsToPhase(collection, 8);
assert.deepEqual(wallets(ended), beforeMentat, 'Mentat must not pay a collected Tech pile again.');
assert.equal(ended.techTokens![fixture.token].spice, 0);
const next = nextNexusModulePaymentsNativeStep(ended);
assert.ok(next);
const continued = applyAction(ended, next.actor, next.action);
assert.deepEqual(wallets(continued), beforeMentat);
nexusInventory(continued);
return continued;
}
// E1 printed p5: real non-Guild off-planet shipment, once for each token controlled,
// held on Heighliners until phase end. Borrowed tariff is not faction identity.
// Publisher-authored source: http://boardgame.bg/dune%20ixians%20and%20tleilaxu%20rulebook.pdf
for (const kind of ['guild-shipment', 'richese-shipment'] as const) {
for (const advanced of [false, true]) {
void test(`${kind} in ${advanced ? 'Advanced with claimed Strongholds' : 'Basic'} pays the original invoice, arrives physically and collects Heighliners only at phase end`, () => {
const f = createNexusModulePaymentsFixture({ kind, advanced, strongholds: advanced });
const before = f.beforePayment.players.find(p => p.id === f.actor)!;
assert.equal(f.cost, kind === 'guild-shipment' ? 3 : 1);
const paid = payNexusModulePaymentsFixture(f);
const after = paid.players.find(p => p.id === f.actor)!;
assert.equal(after.faction, 'atreides');
assert.equal(after.reserves, before.reserves - 5);
assert.equal(after.forces[f.destination!], 5);
assert.equal(after.shipped, true);
assert.equal(after.moved, before.moved);
const expected = wallets(f.beforePayment);
expected[f.actor] -= f.cost;
assert.deepEqual(wallets(paid), expected, 'The earned token pile cannot fund this invoice or a later shipment yet.');
assert.equal(paid.techTokens!.heighliners.spice, f.tokenCount);
assert.equal(paid.techTokens!.heighliners.triggeredTurn, paid.turn);
assert.equal(paid.nexusCards!.cards!.hands[f.actor], null);
assert.equal(paid.nexusCards!.cards!.discard.filter(c => c === (kind === 'guild-shipment' ? 'guild' : 'richese')).length, 1);
rejected(paid, f.actor, f.paymentAction);
nexusInventory(paid);
collectOnce(f, paid, f.tokenCount);
});
}
}
void test('a stopped Richese borrowed shipment spends its singleton but no invoice, force or Heighliners income', () => {
const f = createNexusModulePaymentsFixture({ kind: 'richese-shipment', advanced: true, nativeGuild: true });
const guild = f.game.players.find(p => p.faction === 'guild')!;
const karama = holdNexusRicheseCard(f.game, guild.id, 'karama');
const before = wallets(f.game);
const reserves = f.game.players.find(p => p.id === f.actor)!.reserves;
let game = applyAction(f.game, f.actor, f.paymentAction);
assert.equal(game.decision?.kind, 'guildShipment');
assert.equal(game.pendingShipment!.amount, 5);
assert.equal(game.pendingShipment!.cost, 1);
assert.deepEqual(wallets(game), before, 'Interception precedes actual payment and arrival.');
assert.equal(game.techTokens!.heighliners.spice, 0);
game = applyAction(game, guild.id, { type: 'card', mode: 'special', card: karama.id });
for (let n = 0; (game.response || game.decision) && n < 100; n++) {
const next = nextNexusModulePaymentsNativeStep(game);
assert.ok(next);
game = applyAction(game, next.actor, next.action);
}
assert.ok(!game.response && !game.decision);
assert.deepEqual(wallets(game), before);
assert.equal(game.players.find(p => p.id === f.actor)!.reserves, reserves);
assert.equal(game.players.find(p => p.id === f.actor)!.forces[f.destination!], undefined);
assert.equal(game.techTokens!.heighliners.spice, 0);
assert.equal(game.nexusCards!.cards!.discard.filter(c => c === 'richese').length, 1);
assert.ok(game.discard.some(c => c.id === karama.id));
rejected(game, f.actor, f.paymentAction);
nexusInventory(game);
collectOnce(f, game, 0);
});
void test('native Guild using Richese pricing stays native Guild and cannot activate Heighliners alone', () => {
const f = createNexusModulePaymentsFixture({ kind: 'richese-shipment', ownerFaction: 'guild' });
const paid = payNexusModulePaymentsFixture(f);
const actor = paid.players.find(p => p.id === f.actor)!;
assert.equal(actor.faction, 'guild');
assert.equal(actor.spice, f.beforePayment.players.find(p => p.id === f.actor)!.spice - 1);
assert.equal(actor.forces[f.destination!], 5);
assert.equal(paid.techTokens!.heighliners.spice, 0);
collectOnce(f, paid, 0);
});
void test('Fremen paid borrowed Guild tariff does not relocate southern reserves or change Heighliners source eligibility', () => {
const f = createNexusModulePaymentsFixture({ kind: 'guild-shipment', ownerFaction: 'fremen' });
const paid = payNexusModulePaymentsFixture(f);
const actor = paid.players.find(p => p.id === f.actor)!;
assert.equal(actor.faction, 'fremen');
assert.equal(f.cost, 3);
assert.equal(actor.spice, f.beforePayment.players.find(p => p.id === f.actor)!.spice - 3);
assert.equal(actor.forces[f.destination!], 5);
assert.equal(paid.techTokens!.heighliners.spice, 0, 'A tariff alone does not make the native on-planet reserve source off-planet.');
collectOnce(f, paid, 0);
});
// Printed Nexus Emperor: three additional free beyond limits. Printed Fremen:
// total free three, recorded ordinary-quota interpretation (NEXUS_FREMEN_RULES).
// E1 p5 classifies both actual phase-4 free batches as Axlotl activity, without
// conflating their different ordinary quota ledgers or granting native identity.
for (const kind of ['emperor-revival', 'fremen-revival'] as const) {
for (const strongholds of [false, true]) {
void test(`${kind} consumes the physical card and exact original ledgers${strongholds ? ' without a Stronghold revival bonus' : ''}, then collects Axlotl once`, () => {
const f = createNexusModulePaymentsFixture({ kind, advanced: strongholds, strongholds });
const before = f.beforePayment.players.find(p => p.id === f.actor)!;
let paid = payNexusModulePaymentsFixture(f);
let actor = paid.players.find(p => p.id === f.actor)!;
assert.equal(actor.faction, before.faction);
assert.equal(actor.reserves, before.reserves + 3);
assert.equal(actor.tanks, before.tanks - 3);
assert.deepEqual(wallets(paid), wallets(f.beforePayment), 'No grant, Tech pile or Stronghold card manufactures spendable spice during Revival.');
assert.equal(actor.revived, kind === 'fremen-revival' ? 3 : before.revived);
assert.equal(actor.freeForcesRevived ?? 0, kind === 'fremen-revival' ? 3 : (before.freeForcesRevived ?? 0));
assert.equal(paid.techTokens!.axlotl.spice, f.tokenCount);
assert.equal(paid.techTokens!.axlotl.triggeredTurn, paid.turn);
assert.equal(paid.nexusCards!.cards!.discard.filter(c => c === (kind === 'emperor-revival' ? 'emperor' : 'fremen')).length, 1);
rejected(paid, f.actor, f.paymentAction);
if (kind === 'fremen-revival') {
assert.equal(forceRevivalRemaining(paid, actor), 0);
rejected(paid, f.actor, { type: 'revive', amount: 1 });
} else {
assert.equal(forceRevivalRemaining(paid, actor), 3);
const quote = forceRevivalQuote(paid, actor, 3);
assert.equal(quote.cost, 2, 'Atreides keeps its ordinary two-free/one-paid invoice after the additional grant.');
const wallet = actor.spice;
paid = applyAction(paid, f.actor, { type: 'revive', amount: 3 });
actor = paid.players.find(p => p.id === f.actor)!;
assert.equal(actor.reserves, before.reserves + 6);
assert.equal(actor.tanks, before.tanks - 6);
assert.equal(actor.spice, wallet - quote.cost);
assert.equal(actor.revived, 3);
assert.equal(actor.freeForcesRevived, 2);
assert.equal(paid.techTokens!.axlotl.spice, f.tokenCount, 'Another genuine free batch cannot accrue the same token twice.');
}
nexusInventory(paid);
collectOnce(f, paid, f.tokenCount);
});
}
}
for (const kind of ['emperor-revival', 'fremen-revival'] as const) {
void test(`${kind} preserves actual Advanced elite identity and one-per-turn cap, including bot participation`, () => {
const f = createNexusModulePaymentsFixture({ kind, advanced: true, strongholds: true,
ownerFaction: kind === 'emperor-revival' ? 'fremen' : 'emperor', eliteTanks: 2 });
const view = viewGame(f.game, f.actor);
const offer = kind === 'emperor-revival' ? view.nexusEmperorSecretAlly!.revival : view.nexusFremenRevival!;
assert.deepEqual(offer.eliteOptions, [0, 1]);
rejected(f.game, f.actor, { ...f.paymentAction, elite: 2 });
view.players.find(p => p.id === f.actor)!.bot = 'Hard';
const bot = botActions(view).find(action => action.type === f.paymentAction.type);
assert.ok(bot, 'The actual bot must participate in the newly admitted module option.');
const paid = applyAction(f.game, f.actor, bot);
const actor = paid.players.find(p => p.id === f.actor)!;
const before = f.beforePayment.players.find(p => p.id === f.actor)!;
assert.equal(actor.elites!.reserves, before.elites!.reserves + 1);
assert.equal(actor.elites!.tanks, before.elites!.tanks - 1);
assert.equal(actor.elites!.revived, 1);
assert.equal(actor.elites!.reserves + actor.elites!.tanks
+ Object.values(actor.elites!.forces).reduce((sum, count) => sum + count, 0),
before.elites!.reserves + before.elites!.tanks
+ Object.values(before.elites!.forces).reduce((sum, count) => sum + count, 0));
rejected(paid, f.actor, { type: 'revive', amount: 1, elite: 1 });
nexusInventory(paid);
});
}
void test('Fremen late return and exact-three shortage remain unavailable; a real Truthtrance blocks both Nexus returns', () => {
const f = createNexusModulePaymentsFixture({ kind: 'fremen-revival' });
const late = applyAction(f.game, f.actor, { type: 'revive', amount: 1 });
assert.match(viewGame(late, f.actor).nexusFremenRevival!.blocked!, /before ordinary force revivals/);
rejected(late, f.actor, f.paymentAction);
for (const kind of ['fremen-revival', 'emperor-revival'] as const) {
const short = createNexusModulePaymentsFixture({ kind, tanks: 2 });
rejected(short.game, short.actor, short.paymentAction);
const quiet = createNexusModulePaymentsFixture({ kind });
const questioner = quiet.game.players.find(p => p.id !== quiet.actor)!;
const card = holdNexusRicheseCard(quiet.game, questioner.id, 'truthtrance');
const pending = applyAction(quiet.game, questioner.id, { type: 'card', card: card.id });
assert.ok(pending.truthtrance);
rejected(pending, quiet.actor, quiet.paymentAction);
}
});
void test('Strongholds alone grants no revival bonus or token income', () => {
const f = createNexusModulePaymentsFixture({ kind: 'fremen-revival', advanced: true, strongholds: true, tech: false });
const before = f.beforePayment.players.find(p => p.id === f.actor)!;
const paid = payNexusModulePaymentsFixture(f);
const actor = paid.players.find(p => p.id === f.actor)!;
assert.equal(actor.reserves, before.reserves + 3);
assert.equal(actor.revived, 3);
assert.equal(actor.freeForcesRevived, 3);
assert.deepEqual(wallets(paid), wallets(f.beforePayment));
assert.equal(forceRevivalRemaining(paid, actor), 0);
rejected(paid, f.actor, { type: 'revive', amount: 1 });
});
void test('new module admission rejects too few Tech seats, Basic Strongholds, unrelated modules and already-owned components', () => {
for (const boundary of ['two-tech-seats', 'basic-strongholds', 'skills', 'owned-tech', 'owned-stronghold'] as const) {
let lobby = createGame('NEXUSMODULEBOUNDARY', newPlayer('a', 'Atreides', 'atreides'), boundary !== 'basic-strongholds');
joinGame(lobby, newPlayer('h', 'Harkonnen', 'harkonnen'));
if (boundary !== 'two-tech-seats') joinGame(lobby, newPlayer('e', 'Emperor', 'emperor'));
lobby.nexusCards = { cards: null, phase: null };
if (boundary === 'owned-stronghold') lobby.strongholdCards = createStrongholdCards();
else if (boundary !== 'basic-strongholds') lobby = applyAction(lobby, lobby.host, { type: 'techTokens', enabled: true });
for (const player of lobby.players) lobby = applyAction(lobby, player.id, { type: 'ready' });
if (boundary === 'basic-strongholds') lobby.strongholdCards = createStrongholdCards();
if (boundary === 'skills') lobby.leaderSkills = createLeaderSkills(() => 0.5);
if (boundary === 'owned-tech') lobby.techTokens!.axlotl.owner = 'a';
if (boundary === 'owned-stronghold') lobby.strongholdCards!.owners.arrakeen = 'a';
const before = structuredClone(lobby);
assert.throws(() => initializeNexusGameForAudit(lobby));
assert.deepEqual(lobby, before);
}
// Selected native-expansion cards/factions are not another path to admission.
let lobby = createGame('NEXUSMODULEDECK', newPlayer('a', 'Atreides', 'atreides'), false, ['ix']);
joinGame(lobby, newPlayer('h', 'Harkonnen', 'harkonnen'));
joinGame(lobby, newPlayer('e', 'Emperor', 'emperor'));
lobby.nexusCards = { cards: null, phase: null };
lobby.techTokens = createTechTokens();
for (const player of lobby.players) lobby = applyAction(lobby, player.id, { type: 'ready' });
assert.throws(() => initializeNexusGameForAudit(lobby));
});
for (const strongholds of [false, true]) {
void test(`original Emperor bank-auction purchase${strongholds ? ' with genuinely claimed Advanced Strongholds' : ' with Tech'} keeps the winning wallet, delivers the lot once and activates no technology`, () => {
const f = createNexusModulePaymentsFixture({ kind: 'emperor-purchase',
advanced: strongholds, strongholds });
const before = f.beforePayment;
const auction = before.auction!;
const lot = auction.cards[auction.index];
const buyer = before.players.find(p => p.id === f.actor)!;
assert.equal(auction.bid, 2);
assert.ok(buyer.spice >= auction.bid, 'The real buyer must possess the original invoice amount.');
assert.equal(buyer.hand.some(card => card.id === lot.id), false);
const paid = applyAction(f.game, f.actor, f.paymentAction);
const after = paid.players.find(p => p.id === f.actor)!;
assert.deepEqual(wallets(paid), wallets(before), 'Emperor Secret Ally retains the actual personal winning payment.');
assert.equal(after.hand.filter(card => card.id === lot.id).length, 1);
assert.equal(after.faction, buyer.faction);
assert.equal(paid.nexusCards!.cards!.hands[f.actor], null);
assert.equal(paid.nexusCards!.cards!.discard.filter(card => card === 'emperor').length, 1);
assert.equal(paid.nexusEmperorPurchaseHistory!.length, 1);
const receipt = paid.nexusEmperorPurchaseHistory![0];
assert.equal(receipt.card, lot.id);
assert.equal(receipt.price, 2);
assert.equal(receipt.beforeSpice, buyer.spice);
assert.equal(receipt.afterSpice, buyer.spice);
assert.deepEqual(paid.techTokens, before.techTokens,
'A bank purchase is neither a shipment, a free revival nor CHOAM Charity.');
assert.deepEqual(paid.strongholdCards, before.strongholdCards);
assert.ok(!paid.decision || paid.decision.kind !== 'auctionPayment',
'The original winner payment cannot remain open after card delivery.');
if (paid.auction) assert.equal(paid.auction.index, auction.index + 1);
rejected(paid, f.actor, f.paymentAction);
nexusInventory(paid);
const continued = advanceNexusModulePaymentsToPhase(paid, 4);
assert.equal(continued.players.find(p => p.id === f.actor)!.hand.filter(card => card.id === lot.id).length, 1);
assert.equal(continued.nexusEmperorPurchaseHistory!.length, 1);
assert.deepEqual(continued.techTokens, before.techTokens,
'Completing the real Bidding round cannot mint a delayed technology pile for that purchase.');
nexusInventory(continued);
});
}
+29 -15
View File
@@ -4,6 +4,7 @@ import { readFileSync, statSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseArgs } from 'node:util';
import { classicNexusModulesProfile } from '../game/nexus-module-profile';
import { nativeFactionTechProfile } from '../game/faction-module-profile';
import { botActions } from '../game/bots';
import { treacheryDeck } from '../game/cards';
@@ -40,7 +41,7 @@ const DEFAULT_SEED = 20_260_926;
const DEFAULT_MAX_ACTIONS = 3_500;
const DIFFICULTIES = ['Easy', 'Medium', 'Hard', 'Brutal'] as const;
type Profile = 'base' | 'choam' | 'ecaz' | 'ecaz-treachery' | 'ecaz-occupy' | 'moritani-assassinate' | 'stronghold-factions' | 'combined' | 'combined-nexus' | 'combined-homeworld-nexus' | 'homeworld' | 'nexus' | 'homeworld-nexus' | 'choam-roster' | 'ecaz-roster' | 'ix-roster' | 'choam-nexus' | 'ecaz-nexus' | 'ix-nexus' | 'moritani-skills' | 'tleilaxu-skills' | 'ix-skills' | 'choam-skills' | 'richese-skills' | 'ecaz-skills' | 'skills-tech' | 'skills-stronghold' | 'skills-stronghold-tech'
| 'factions-tech' | 'stronghold-factions-tech';
| 'factions-tech' | 'stronghold-factions-tech' | 'nexus-tech' | 'nexus-stronghold' | 'nexus-stronghold-tech';
type Rules = 'basic' | 'advanced';
type Scenario = {
@@ -144,6 +145,17 @@ const MODULE_SCENARIOS: readonly Scenario[] = [
})),
),
];
const CLASSIC_NEXUS_MODULE_SCENARIOS: readonly Scenario[] =
(['nexus-tech', 'nexus-stronghold', 'nexus-stronghold-tech'] as const)
.flatMap((profile, band) => MODULE_SCENARIOS
.filter(scenario => scenario.profile === 'nexus' &&
(profile === 'nexus-tech' || scenario.rules === 'advanced') &&
(profile === 'nexus-stronghold' || scenario.roster.length >= 3))
.map((scenario, index): Scenario => ({ ...scenario, profile, ordinal: 800 + band * 100 + index })));
const nexusModuleProfile = (profile: string) =>
profile === 'nexus-tech' || profile === 'nexus-stronghold' || profile === 'nexus-stronghold-tech';
const nexusTechProfile = (profile: string) => profile === 'nexus-tech' || profile === 'nexus-stronghold-tech';
const nexusStrongholdProfile = (profile: string) => profile === 'nexus-stronghold' || profile === 'nexus-stronghold-tech';
const EXPANSION_ROSTER_SCENARIOS: readonly Scenario[] = [
{ profile: 'choam-roster' as const, expansions: ['choam'], roster: ['choam', 'richese', 'emperor', 'guild', 'harkonnen', 'fremen'] as FactionId[] },
{ profile: 'ecaz-roster' as const, expansions: ['ecaz'], roster: ['moritani', 'ecaz', 'atreides', 'beneGesserit', 'harkonnen', 'guild'] as FactionId[] },
@@ -429,7 +441,7 @@ type Result = {
function usage() {
return (
'Usage: node --import tsx tools/faction-games.ts --out NEW_PRIVATE_DIR ' +
'[--seed UINT32] [--profile all|base|choam|ecaz|ecaz-treachery|ecaz-occupy|moritani-assassinate|stronghold-factions|factions-tech|stronghold-factions-tech|combined|combined-nexus|combined-homeworld-nexus|homeworld|nexus|homeworld-nexus|choam-roster|ecaz-roster|ix-roster|choam-nexus|ecaz-nexus|ix-nexus|moritani-skills|tleilaxu-skills|ix-skills|choam-skills|richese-skills|ecaz-skills|skills-tech|skills-stronghold|skills-stronghold-tech] ' +
'[--seed UINT32] [--profile all|base|choam|ecaz|ecaz-treachery|ecaz-occupy|moritani-assassinate|stronghold-factions|factions-tech|stronghold-factions-tech|combined|combined-nexus|combined-homeworld-nexus|homeworld|nexus|nexus-tech|nexus-stronghold|nexus-stronghold-tech|homeworld-nexus|choam-roster|ecaz-roster|ix-roster|choam-nexus|ecaz-nexus|ix-nexus|moritani-skills|tleilaxu-skills|ix-skills|choam-skills|richese-skills|ecaz-skills|skills-tech|skills-stronghold|skills-stronghold-tech] ' +
'[--rules both|basic|advanced] [--players all|2|3|4|5|6] [--max-actions POSITIVE] ' +
'[--resume FAILED_GAME.json]\n' +
'Runs genuine setup and gameplay offline. Default/all keeps the six expansion samples; combined-nexus and combined-homeworld-nexus add five/six-seat Basic/Advanced all-expansion samples. Base, Homeworld, Nexus, Homeworld-Nexus, expansion roster, paired expansion Nexus, Ecaz card variant, Advanced Moritani assassination and skill profiles select their documented rosters. Stronghold-factions is Advanced only: Ixians + CHOAM, native Ixians + Tleilaxu, or native CHOAM + Richese with classic opponents, two through six seats; mixed four-native rosters also run at four through six seats. Selected Ix/CHOAM decks determine the canonical 47/35 Treachery Cards; Richese has a separate ten-card cache and all samples have six separate Stronghold Cards without other modules. Scenario names identify their native roster. --players requires a supported profile. Output must be a new private directory outside the checkout.' +
@@ -441,6 +453,7 @@ function usage() {
+ '\nSkills-stronghold uses fresh Advanced classic or supported native two-through-six-seat skill rosters with all14 skills and six original Stronghold Cards. Skills-stronghold-tech adds three original Tech Tokens and requires three-through-six seats. Original native decks/cache, real end-Mentat Stronghold ownership and printed/first-Storm Tech assignment remain; other modules, mixed E3 families, public starts and played-game conversion stay separate.'
+ '\nAll three combined skill/module profiles additionally admit standalone native Ecaz OR Moritani with classic opponents and the exact ecaz33 deck. Original five-disc Ecaz/Duke assignment and Advanced Harkonnen exclusion remain; Advanced Moritani retains its original non-Harkonnen skill-first assassination and normal-traitor forfeiture. E3 pairs, mixed-family rosters, allied Occupy skills and other overlays stay guarded.'
+ '\nFactions-tech uses native E1/E2 families or standalone Ecaz OR Moritani plus classics, three-through-six seats, Basic/Advanced, original selected decks and three Tech Tokens without Skills. Stronghold-factions-tech is its Advanced six-card Stronghold composition. Native Tech setup, real payments, typed battle rewards and selected Ecaz lead attribution remain; Basic odd Occupy, Richese cache/No-Field rulings, other modules and public starts stay guarded.'
+ '\nNexus-tech preserves classic/base33/all12 Nexus and original Tech3..6 in Basic/Advanced. Nexus-stronghold adds six original Stronghold Cards in Advanced2..6; nexus-stronghold-tech requires3..6. Original closing Nexus deals, actual free/paid source identity, phase-end Tech, held support and postbattle card/reward windows remain. Skills, Homeworlds, expansions, other overlays, unresolved effects, public starts and played-game conversion stay separate.'
);
}
@@ -496,14 +509,14 @@ function scenarioName(scenario: Scenario) {
return `${scenario.profile}${suffix}-${scenario.roster.length}-${scenario.rules}`;
}
return scenario.profile === 'base' || scenario.profile === 'homeworld' || scenario.profile === 'nexus' || scenario.profile === 'homeworld-nexus' || scenario.profile === 'combined-nexus' || scenario.profile === 'combined-homeworld-nexus' ||
scenario.profile === 'choam-roster' || scenario.profile === 'ecaz-roster' || scenario.profile === 'ecaz-treachery' || scenario.profile === 'ecaz-occupy' || scenario.profile === 'moritani-assassinate' || scenario.profile === 'ix-roster' || scenario.profile === 'choam-nexus' || scenario.profile === 'ecaz-nexus' || scenario.profile === 'ix-nexus' || skillProfile(scenario.profile)
scenario.profile === 'choam-roster' || scenario.profile === 'ecaz-roster' || scenario.profile === 'ecaz-treachery' || scenario.profile === 'ecaz-occupy' || scenario.profile === 'moritani-assassinate' || scenario.profile === 'ix-roster' || scenario.profile === 'choam-nexus' || scenario.profile === 'ecaz-nexus' || scenario.profile === 'ix-nexus' || skillProfile(scenario.profile) || nexusModuleProfile(scenario.profile)
? `${scenario.profile}-${scenario.roster.length}-${scenario.rules}`
: `${scenario.profile}-${scenario.rules}`;
}
function parseProfile(value: string | undefined) {
const profile = value ?? 'all';
if (!nativeTechProfile(profile) && !['all', 'base', 'choam', 'ecaz', 'ecaz-treachery', 'ecaz-occupy', 'moritani-assassinate', 'stronghold-factions', 'combined', 'combined-nexus', 'combined-homeworld-nexus', 'homeworld', 'nexus', 'homeworld-nexus', 'choam-roster', 'ecaz-roster', 'ix-roster', 'choam-nexus', 'ecaz-nexus', 'ix-nexus', 'moritani-skills', 'tleilaxu-skills', 'ix-skills', 'choam-skills', 'richese-skills', 'ecaz-skills', 'skills-tech', 'skills-stronghold', 'skills-stronghold-tech'].includes(profile))
if (!nativeTechProfile(profile) && !nexusModuleProfile(profile) && !['all', 'base', 'choam', 'ecaz', 'ecaz-treachery', 'ecaz-occupy', 'moritani-assassinate', 'stronghold-factions', 'combined', 'combined-nexus', 'combined-homeworld-nexus', 'homeworld', 'nexus', 'homeworld-nexus', 'choam-roster', 'ecaz-roster', 'ix-roster', 'choam-nexus', 'ecaz-nexus', 'ix-nexus', 'moritani-skills', 'tleilaxu-skills', 'ix-skills', 'choam-skills', 'richese-skills', 'ecaz-skills', 'skills-tech', 'skills-stronghold', 'skills-stronghold-tech'].includes(profile))
throw new Error('--profile must name a documented base, module, expansion or native skill sample profile.');
return profile as Profile | 'all';
}
@@ -549,11 +562,11 @@ function freshGame(scenario: Scenario) {
player.ready = true;
}
if (scenario.ecazTreachery) game.ecazTreachery = true;
if (scenario.profile === 'skills-tech' || scenario.profile === 'skills-stronghold-tech' || nativeTechProfile(scenario.profile)) game.techTokens = createTechTokens();
if (strongholdSkillProfile(scenario.profile)) game.strongholdCards = createStrongholdCards();
if (scenario.profile === 'skills-tech' || scenario.profile === 'skills-stronghold-tech' || nativeTechProfile(scenario.profile) || nexusTechProfile(scenario.profile)) game.techTokens = createTechTokens();
if (strongholdSkillProfile(scenario.profile) || nexusStrongholdProfile(scenario.profile)) game.strongholdCards = createStrongholdCards();
if (scenario.profile === 'homeworld' || scenario.profile === 'homeworld-nexus' || scenario.profile === 'combined-homeworld-nexus')
game.homeworlds = { custody: null };
if (scenario.profile === 'nexus' || scenario.profile === 'homeworld-nexus' || scenario.profile === 'choam-nexus' || scenario.profile === 'ecaz-nexus' || scenario.profile === 'ix-nexus' || scenario.profile === 'combined-nexus' || scenario.profile === 'combined-homeworld-nexus')
if (scenario.profile === 'nexus' || nexusModuleProfile(scenario.profile) || scenario.profile === 'homeworld-nexus' || scenario.profile === 'choam-nexus' || scenario.profile === 'ecaz-nexus' || scenario.profile === 'ix-nexus' || scenario.profile === 'combined-nexus' || scenario.profile === 'combined-homeworld-nexus')
game.nexusCards = { cards: null, phase: null };
if (scenario.profile === 'combined-nexus' || scenario.profile === 'combined-homeworld-nexus')
return initializeCombinedNexusGameForAudit(game, scenario.profile === 'combined-homeworld-nexus');
@@ -571,7 +584,7 @@ function freshGame(scenario: Scenario) {
? initializeLeaderSkillsGameForAudit(game)
: scenario.profile === 'homeworld'
? initializeHomeworldGameForAudit(game)
: scenario.profile === 'nexus' || scenario.profile === 'homeworld-nexus'
: scenario.profile === 'nexus' || nexusModuleProfile(scenario.profile) || scenario.profile === 'homeworld-nexus'
? initializeNexusGameForAudit(game)
: scenario.profile === 'base'
? initializeBaseGameForAudit(game)
@@ -616,17 +629,17 @@ function resumedGame(path: string) {
game.discoveries ||
game.discoveryStash ||
game.greatMaker ||
(game.techTokens && !composedSkills && !nativeFactionTechProfile(game)) ||
(game.techTokens && !composedSkills && !nativeFactionTechProfile(game) && !classicNexusModulesProfile(game)) ||
game.mentatQuestionPreview
)
throw new Error('--resume sample scenarios exclude unsupported optional modules.');
const scenario = [...SCENARIOS, ...COMBINED_NEXUS_SCENARIOS, ...COMBINED_HOMEWORLD_NEXUS_SCENARIOS, ...BASE_SCENARIOS, ...MODULE_SCENARIOS, ...EXPANSION_ROSTER_SCENARIOS, ...ECAZ_TREACHERY_SCENARIOS, ...ECAZ_OCCUPY_SCENARIOS, ...PAIRED_NEXUS_SCENARIOS, ...MORITANI_ASSASSINATE_SCENARIOS, ...STRONGHOLD_FACTIONS_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...IX_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS, ...RICHESE_SKILLS_SCENARIOS, ...ECAZ_SKILLS_SCENARIOS, ...SKILLS_TECH_SCENARIOS, ...SKILLS_STRONGHOLD_SCENARIOS, ...NATIVE_TECH_SCENARIOS].find(
const scenario = [...SCENARIOS, ...COMBINED_NEXUS_SCENARIOS, ...COMBINED_HOMEWORLD_NEXUS_SCENARIOS, ...BASE_SCENARIOS, ...MODULE_SCENARIOS, ...EXPANSION_ROSTER_SCENARIOS, ...ECAZ_TREACHERY_SCENARIOS, ...ECAZ_OCCUPY_SCENARIOS, ...PAIRED_NEXUS_SCENARIOS, ...MORITANI_ASSASSINATE_SCENARIOS, ...STRONGHOLD_FACTIONS_SCENARIOS, ...MORITANI_SKILLS_SCENARIOS, ...TLEILAXU_SKILLS_SCENARIOS, ...IX_SKILLS_SCENARIOS, ...CHOAM_SKILLS_SCENARIOS, ...RICHESE_SKILLS_SCENARIOS, ...ECAZ_SKILLS_SCENARIOS, ...SKILLS_TECH_SCENARIOS, ...SKILLS_STRONGHOLD_SCENARIOS, ...NATIVE_TECH_SCENARIOS, ...CLASSIC_NEXUS_MODULE_SCENARIOS].find(
(candidate) =>
(candidate.profile === 'homeworld' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.homeworlds &&
(candidate.profile === 'nexus' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'choam-nexus' || candidate.profile === 'ecaz-nexus' || candidate.profile === 'ix-nexus' || candidate.profile === 'combined-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.nexusCards &&
(candidate.profile === 'nexus' || nexusModuleProfile(candidate.profile) || candidate.profile === 'homeworld-nexus' || candidate.profile === 'choam-nexus' || candidate.profile === 'ecaz-nexus' || candidate.profile === 'ix-nexus' || candidate.profile === 'combined-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.nexusCards &&
skillProfile(candidate.profile) === !!game.leaderSkills &&
(candidate.profile === 'skills-tech' || candidate.profile === 'skills-stronghold-tech' || nativeTechProfile(candidate.profile)) === !!game.techTokens &&
(nativeStrongholdProfile(candidate.profile) || strongholdSkillProfile(candidate.profile)) === !!game.strongholdCards &&
(candidate.profile === 'skills-tech' || candidate.profile === 'skills-stronghold-tech' || nativeTechProfile(candidate.profile) || nexusTechProfile(candidate.profile)) === !!game.techTokens &&
(nativeStrongholdProfile(candidate.profile) || strongholdSkillProfile(candidate.profile) || nexusStrongholdProfile(candidate.profile)) === !!game.strongholdCards &&
(candidate.profile === 'ecaz-treachery' || candidate.ecazTreachery === true) === !!game.ecazTreachery &&
assassinationScenario(candidate) === !!game.moritaniAssassinatePreview &&
(candidate.profile === 'ecaz-occupy' || (nativeTechProfile(candidate.profile) && candidate.roster.includes('ecaz'))) === !!game.ecazOccupyPreview &&
@@ -836,19 +849,20 @@ async function main() {
const profile = parseProfile(values.profile);
const rules = parseRules(values.rules);
const players = parsePlayers(values.players);
if (supplied('players') && !nativeTechProfile(profile) && !['base', 'homeworld', 'nexus', 'homeworld-nexus', 'combined-nexus', 'combined-homeworld-nexus', 'choam-roster', 'ecaz-roster', 'ecaz-treachery', 'ecaz-occupy', 'moritani-assassinate', 'stronghold-factions', 'ix-roster', 'choam-nexus', 'ecaz-nexus', 'ix-nexus'].includes(profile) && !skillProfile(profile))
if (supplied('players') && !nexusModuleProfile(profile) && !nativeTechProfile(profile) && !['base', 'homeworld', 'nexus', 'homeworld-nexus', 'combined-nexus', 'combined-homeworld-nexus', 'choam-roster', 'ecaz-roster', 'ecaz-treachery', 'ecaz-occupy', 'moritani-assassinate', 'stronghold-factions', 'ix-roster', 'choam-nexus', 'ecaz-nexus', 'ix-nexus'].includes(profile) && !skillProfile(profile))
throw new Error('--players requires a roster or optional-module profile.');
if ((profile === 'combined-nexus' || profile === 'combined-homeworld-nexus') && players !== 'all' && ![5, 6].includes(players))
throw new Error(`--profile ${profile} supports only five or six players.`);
if (profile === 'moritani-assassinate' && rules === 'basic')
throw new Error('Moritani assassination samples require Advanced rules.');
if ((profile === 'stronghold-factions' || strongholdSkillProfile(profile)) && rules === 'basic')
if ((profile === 'stronghold-factions' || strongholdSkillProfile(profile) || nexusStrongholdProfile(profile)) && rules === 'basic')
throw new Error('Stronghold samples require Advanced rules.');
if ((profile === 'skills-tech' || profile === 'skills-stronghold-tech') && players === 2)
throw new Error('Leader Skills with Tech Tokens samples require three through six players.');
const resume = values.resume ? resumedGame(values.resume) : null;
const requestedSamples = profile === 'homeworld' || profile === 'nexus' || profile === 'homeworld-nexus'
? MODULE_SCENARIOS
: nexusModuleProfile(profile) ? CLASSIC_NEXUS_MODULE_SCENARIOS
: profile === 'choam-roster' || profile === 'ecaz-roster' || profile === 'ix-roster' ? EXPANSION_ROSTER_SCENARIOS
: profile === 'ecaz-treachery' ? ECAZ_TREACHERY_SCENARIOS
: profile === 'ecaz-occupy' ? ECAZ_OCCUPY_SCENARIOS
+1
View File
@@ -30,6 +30,7 @@ async function main() {
'\nLeader-skills and banker-income also preserve canonical unused Stronghold Cards in fresh Advanced classic or supported native two-through-six-seat lobbies. Optional Tech Tokens requires three-through-six seats. All14 original skills, decks/cache and actual end-Mentat Stronghold assignment remain. Basic Strongholds, mixed E3 families, other overlays, public activation and save conversion stay excluded.',
'\nThese skill/module entries additionally preserve standalone Ecaz OR Moritani ecaz33/all14 profiles. Tech is Basic/Advanced three-through-six seats; Strongholds is Advanced two-through-six. Native Ecaz five-disc/Duke assignment and Advanced Harkonnen exclusion, plus original Advanced Moritani skill-first assassination/normal-call forfeiture, remain. E3 pairs, mixed families, allied Occupy skills and other overlays are not opened.',
'\nWithout Skills, factions preserves unused original Tech Tokens in fresh 3–6-seat Basic/Advanced selected E1/E2 native or standalone Ecaz OR Moritani lobbies with classic opponents; stronghold-factions composes them with Advanced Stronghold Cards. Original physical inventories, first-Storm owners, selected Ecaz lead rewards, even-force Basic Occupy and non-Harkonnen Advanced Moritani assassination remain. Skills, unrelated overlays, paired/mixed E3 rosters and public starts remain separate.',
'\nNexus preserves selected unused original Tech Tokens in classic/base33/all12 three-through-six-seat Basic/Advanced lobbies, and/or unused Stronghold Cards in Advanced two-through-six-seat lobbies; both requires3+. Genuine first-Storm/end-Mentat custody and closing Nexus deals remain. Borrowed tariff/force identity, original free-return ledgers, phase-end industry, held support and retention/winner rewards reuse existing handlers. Skills, Homeworlds in this composition, expansions, unrelated previews, unresolved effects and public starts remain separate.',
);
return;
}