Compose classic Nexus and Leader Skills with physical Suk rescue

This commit is contained in:
Quintine committed 2026-10-04 20:41:39 +11:00
1 parent c8482a2d96
commit 3ae5e1f994
18 files changed
+1587 -18

No files matched your search

+20
View File
@@ -16,6 +16,26 @@ open mode gates.
## Current checkpoint and work
**Classic Nexus / Leader Skills — bounded Development Verified:** fresh
classic/base33/all14 Skills/all12 Nexus, Basic/Advanced2..6, explicit original
`leader-skills --nexus-cards`, without other overlays/public starts/new rulings.
New40/affected151, types/lint/build, ten actual setup and twelve native rule
programs, four legal Suk/free-return policies and ten genuine2..6-seat games
pass (4,446 actions/no rejection,117 JSON continuations/eight closing choices).
Natural Cunning/Suk is not claimed. Actual normal and trained Suk revealed
two pending-Cunning lifecycle failures; matching original casualty commitment
now stays pending through rescue and completes only at physical settlement.
Original backed-up CLI **HKCLYXPK / APXBER55 v8/setup** preserves exact
starting hands/offers/actors after a scoped original14 shuffle.
Human390px **HKCLYXPK v20 / APXBER55 v24, Collection** chooses posture,
Cunning, Caid3 or Fenring6/dial6/support3/Shield. Normal loss4 saves1 to reserves
(14 reserve/3 Tanks/3 board); trained loss5 keeps the original7 pending until
rescue3 (1 kept/2 returned), then15 reserve/2 Tanks/3 board. Both preserve all5
starred reserves, zero starred Tanks, Shield/Suk, support10→7 once and separate
city2→9. Actual phone controls/inspectors/refresh and no root390px overflow
pass; only dedicated QA rooms change. [Scope and bounded evidence](NEXUS_CARD_RULES.md#classic-nexus-and-leader-skills-composition--4-october-2026)
preserve borrowed Smuggler, Atreides Suk/KH, pending Nexus and all release gates.
**Paired E1/E2 Nexus / Tech / Strongholds — bounded Development Verified:**
fresh both Ixians+Tleilaxu OR CHOAM+Richese plus classics/one family deck
preserves Tech3..6 Basic/Advanced and Strongholds2..6 Advanced without Skills.
+30
View File
@@ -29,6 +29,36 @@ Advanced native/combined-module skills remain guarded. See
[native preview scope](MENTAT_QUESTION.md#native-skill-integration--2-october-2026).
## Classic Nexus composition — 4 October 2026
Fresh `leader-skills --nexus-cards` composes classic/base33/all14 Skills/all12
Nexus, Basic/Advanced2..6, without native factions, Tech, Strongholds, Homeworlds,
Banker/Mentat previews or other overlays. Original starting hands may already
be dealt before skill selection; supplied saved offers/hands and authenticated
actors are continued, not replaced. Actual closing Nexus deals remain required.
Original free-three ledgers, bank purchase and Traitor Deck draw/return preserve
physical skill custody. Temporary Emperor Sardaukar remain ordinary counters
through actual discipline bonuses, paid support, casualties and Suk rescue.
The reproduced normal/selected trained rescue failures now retain the identical
original Cunning casualty commitment until actual physical settlement, then
complete its receipt once. Rescue never repeats support or converts actual
normal counters into starred inventory.
New40/affected151, types/lint/build, ten genuine setup programs, twelve actual
rule programs, four legal rescue/free-return policies and ten genuine2..6-seat
games (4,446 actions/no rejection,117 JSON continuations/eight actual closing
choices) pass. Human390px **HKCLYXPK v20 / APXBER55 v24, Collection** seals
Caid3 or trained Fenring6/dial6/support3/Shield. Normal loss4 saves1 to reserves
(14 reserve/3 Tanks/3 board); trained loss5 saves3 with1 kept/2 returned
(15 reserve/2 Tanks/3 board). All5 native Sardaukar stay reserved and Shield/
Suk remain; actual support10→7 precedes separate city2→9. Pending trained
rescue retains all7 original physical counters until selection resolves.
Original posture/Cunning/casualty/rescue/cleanup/inspectors and refreshed phone
outcomes pass. Controlled original shuffle/order/relocations are labeled.
[Canonical scope, source and evidence](NEXUS_CARD_RULES.md#classic-nexus-and-leader-skills-composition--4-october-2026)
preserves the other explicit skill/Nexus rulings, source guards and all gates.
## Standalone E3 module composition — 4 October 2026
Fresh original Ecaz OR Moritani skill profiles preserve unused Tech Tokens in
+81
View File
@@ -2,6 +2,87 @@
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 Nexus and Leader Skills composition — 4 October 2026
The existing fresh local `leader-skills --nexus-cards` entry explicitly
composes classic/base33/all14 Skills/all12 Nexus in Basic/Advanced2..6.
Original prediction, starting Treachery, actual two-card skill offers/
assignment and Traitor order remain. Starting hands may already be dealt
in the initializer's saved setup; continuing it never redeals those hands
or substitutes its saved offers. Nexus still has no initial deal: a genuine
qualifying alliance Nexus must close before an unallied player can draw.
This combines the independent Nexus lifecycle below and the original
[fourteen-card skill source](LEADER_SKILLS_RULES.md). No Tech, Strongholds,
Homeworlds, native factions/decks, Banker/Mentat preview, independent cards,
unrelated overlays, public start or played-game conversion is admitted.
Other card-specific module guards remain, including borrowed Smuggler
shipment arithmetic, Advanced Atreides Suk/KH, modified Smuggler collection,
borrowed Voice, worm protection and Tleilaxu revival accounting.
Original Emperor extra-three and Fremen ordinary/free-three returns retain
their distinct ledgers, actual counters, wallet and Advanced elite cap.
Neither changes physical skill assignment. The normal bank-auction purchase
retains the actual buyer price and delivers the actual lot once; it creates
no bank payment or normal Banker award. Harkonnen Cunning draws/returns
the actual Traitor Deck card without changing trained-disc custody or
undoing a committed true/false traitor call after identity custody changes.
Emperor Cunning still turns exactly five existing ordinary counters into
temporary effective Sardaukar for one battle, never physical starred pieces.
Actual normal/skilled disciplines affect strength, not printed-disc bounty;
original support is paid once. A living normal Suk saves one casualty to
reserves, while a surviving selected trained Suk may save up to three,
keeping one in its original sector and returning the rest to reserves.
The combined path reproduced two real lifecycle failures: normal automatic
rescue left Cunning expecting its pre-loss army, and selected trained rescue
left it expecting the earlier casualty chooser. The fix keeps the Cunning
receipt pending against the identical original Suk casualty commitment while
rescue is selected. It completes only after actual physical rescue settlement,
with original actor/event/turn/territory/choice/signature checks retained.
No provisional marking complete, extra payment, elite conversion or second
loss is added.
```sh
node --import tsx tools/start-prototype.ts --profile leader-skills \
--nexus-cards --db /private/local.sqlite --room ROOMCODE --version 7 \
--out /private/new-classic-nexus-skills-entry
node --import tsx tools/faction-games.ts --profile nexus-skills \
--players all --rules both --out /private/new-classic-nexus-skills-games
```
### Bounded classic skill development evidence
New40/affected151 cases, types/lint/build, ten genuine2..6-seat setup programs,
twelve actual native rule programs and all four legal Suk/free-return policies
pass. Ten genuine2..6-seat Basic/Advanced games complete4,446 accepted actions,
no rejection,117 JSON continuations and eight actual closing Nexus choices.
They do not claim natural Cunning/Suk use (zero Cunning actions).
`/tmp/dune-classic-nexus-skills-games-20261004/report.json` captures unchanged
tree `86d2c73debec01f5d4bb5c06a8a475274562d3ea9f68cb92b6e67c0dab67e4a2`.
Original backed-up CLI **HKCLYXPK / APXBER55 v8/setup** uses a scoped original
all14 shuffle before native deals; other randomness is unchanged. Saved first
starting hands/offers and authenticated actors survive fixture continuation.
Controlled390px **HKCLYXPK v20 / APXBER55 v24, Collection** chooses actual
face-up/behind-shield posture and Cunning, then seals Caid3 or Fenring6,
dial6/support3/Shield versus Master Bewt3/dial0. Original support3 pays
wallet10→7 once; separate Arrakeen Collection2 makes9.
Normal four ordinary losses save one to reserves:14 reserves/3 Tanks/3 board.
Trained five ordinary losses keep the original seven-force army pending while
three-rescue is selected, then keep one, return two and settle:
15 reserves/2 Tanks/3 board. Both preserve all5 actual Sardaukar in reserves,
zero starred Tanks and the original retained Shield/Suk assignment.
Cunning and Suk receipts are complete only after physical settlement.
Original phone posture, Cunning, typed losses, rescue selector, card cleanup,
skill/force inspectors and refreshed outcomes are observed without root390px
overflow. Only dedicated QA rooms change; controlled relocation is labeled.
This is bounded development proof, not full Nexus/Skills, assurance,
calibration, public-start or deployed acceptance.
## Paired E1/E2 Tech and Stronghold composition — 4 October 2026
The existing fresh local `nexus` entry additionally preserves unused original
+12
View File
@@ -89,6 +89,18 @@ See [scope and authority](NEXUS_CARD_RULES.md#classic-tech-and-stronghold-compos
this does not admit native Tleilaxu income, Homeworlds, Skills or seller-paid
and forced-Emperor producers, and leaves their pending questions unchanged.
The separate fresh classic `leader-skills --nexus-cards` composition additionally
admits the original extra-three return and normal bank purchase with all14
Skills but no Tech/Strongholds or other overlays. Exact-three/elite caps,
independent ordinary ledger and physical skill custody remain. Bank purchase
creates no actual bank payment or Banker award. Actual Cunning with normal or
selected trained Suk retains the original typed casualty receipt while rescue
is pending, completing only after physical settlement; its temporary Sardaukar
stay ordinary counters and original support is paid once. [Bounded scope and
evidence](NEXUS_CARD_RULES.md#classic-nexus-and-leader-skills-composition--4-october-2026)
does not answer the separate native income, seller-paid or forced-payer rulings.
## 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)
+9
View File
@@ -103,6 +103,15 @@ The current first path is a deliberate subset of that proposed contract: with Fr
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.
The separate fresh classic `leader-skills --nexus-cards` composition preserves
the same ordinary/free-three ledgers, zero wallet cost, actual counters and
Advanced one-elite cap with all14 original skills, without Tech/Strongholds
or other overlays. It does not change physical training, grant leader revival,
reset prior returns or settle worm-protection reactions. [Scope and bounded
proof](NEXUS_CARD_RULES.md#classic-nexus-and-leader-skills-composition--4-october-2026)
remains separate from complete Fremen/Nexus certification.
`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.
Keep these questions visible: how the three-total interacts with a higher native free rate, prior paid/free returns, low-Homeworld bonus and Recruits; whether a partial group below three is permitted when Tanks cannot supply three; and whether the benefit survives La La La or Tleilaxu prevention. The [existing revival audit](FREMEN_REVIVAL_AUDIT.md) and [Homeworld benefit audit](HOMEWORLD_BENEFITS_RULES.md) do not by themselves answer differently worded Nexus composition.
+1
View File
@@ -207,6 +207,7 @@ See [the cutover and Advanced sequence](MORITANI_ASSASSINATE_LEADERS.md#authorit
| 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) |
| Paired E1/E2 Nexus / Tech / Strongholds | Fresh both Ixians+Tleilaxu OR CHOAM+Richese plus classics with one original family deck/all12 Nexus preserves Tech3..6 Basic/Advanced and/or Strongholds2..6 Advanced, without Skills or other overlays. Original Cunning, typed Suboid/Cyborg loss and equal substitution, Face Dancer reward-before-replacement and draw-before-retirement, two physical No-Fields/one tariff, native industry at phase end and actual held/copy effects reuse original handlers. No mixed families, E3, new interpretations, public starts or played-game conversion. | [Scope, sources and bounded evidence](NEXUS_CARD_RULES.md#paired-e1e2-tech-and-stronghold-composition--4-october-2026), [Richese composition](NEXUS_RICHESE_RULES.md#paired-techstronghold-composition--4-october-2026) |
| Classic Nexus / Leader Skills | Fresh classic/base33/all14 Skills/all12 Nexus, Basic/Advanced2..6, explicit `leader-skills --nexus-cards`, no other overlays/previews. Original starting hands/offers/skill-before-Traitor setup and actual closing Nexus draw remain. Exact extra/ordinary free-return ledgers, real bank purchase and committed Traitor declarations preserve trained-disc custody. Temporary ordinary-as-Sardaukar roles compose actual skill posture/bonuses/support; matching Cunning casualty receipt stays pending through original Suk rescue and completes only after physical settlement. Source-guarded borrowed Smuggler arithmetic, Atreides Suk/KH and unresolved effects remain; no public start, retrofit or new ruling. | [Scope, sources and bounded evidence](NEXUS_CARD_RULES.md#classic-nexus-and-leader-skills-composition--4-october-2026), [Skill runtime](LEADER_SKILLS_RUNTIME.md#classic-nexus-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) |
+20 -3
View File
@@ -1,6 +1,6 @@
import { classicNexusModulesProfile, pairedNexusModulesProfile } from './nexus-module-profile';
import { nativeFactionTechProfile } from './faction-module-profile';
import { advancedMoritaniLeaderSkillsProfile, advancedNativeLeaderSkillsProfile, classicTechLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, strongholdLeaderSkillsProfile } from './leader-skill-profile';
import { advancedMoritaniLeaderSkillsProfile, advancedNativeLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, strongholdLeaderSkillsProfile } from './leader-skill-profile';
import { bribeTimingBlock, maximumBribe, type BribeOptions } from './bribe-options';
import { quoteSpicePlacement, stormExposesTerritory, stormSectorAfter, wormConsumesForces } from './disaster-rules';
import { isStormCardDistance, type StormCardComponent } from './storm-cards';
@@ -3439,7 +3439,13 @@ function nexusSardaukarIntegrity(g: Game) {
'The pending Sardaukar casualties have lost their exact physical army.');
const decisions = homeworldSavedDecisions(g).filter(d => d.kind === 'battleLosses' && d.player === record.receipt.owner);
const committed = continuation?.kind === 'battleResolved' && continuation.event === record.receipt.battle ? continuation.casualties : null;
requireRule(decisions.length > 0 || committed, 'The Nexus Sardaukar battle has lost its casualty choice.');
const rescue = g.pendingSukRescue?.event === record.receipt.battle ? g.pendingSukRescue : null;
requireRule(decisions.length > 0 || committed || rescue?.losses, 'The Nexus Sardaukar battle has lost its casualty choice.');
if (rescue) requireRule(rescue.player === record.receipt.owner &&
rescue.turn === record.receipt.turn && rescue.territory === record.receipt.territory &&
JSON.stringify(rescue.commitment) === JSON.stringify({forces:losses.forces,dial:losses.dial,support:losses.support,options:losses.options}) &&
(!rescue.losses || losses.options.some(option => JSON.stringify(option) === JSON.stringify(rescue.losses))),
'The Suk rescue differs from the committed Nexus Sardaukar casualties.');
for (const decision of decisions) requireRule(decision.kind === 'battleLosses' &&
decision.territory === record.receipt.territory && JSON.stringify(decision.options) === JSON.stringify(losses.options),
'The casualty choices differ from the committed Nexus Sardaukar plan.');
@@ -9394,7 +9400,7 @@ export function initializeLeaderSkillsGameForAudit(state: Game): Game {
requireRule(!state.leaderSkills, 'Leader Skills cannot redeal existing skill cards.');
const g = structuredClone(state);
g.leaderSkills = createLeaderSkills(random);
const initialized = initializeSetupGameForAudit(g, false, false, false, false, true,
const initialized = initializeSetupGameForAudit(g, false, classicNexusLeaderSkillsProfile(g), false, false, true,
g.expansions.length === 1 && g.expansions[0] === 'choam', nativeExpansionLeaderSkillsProfile(g));
if (advancedMoritaniLeaderSkillsProfile(initialized)) initializeMoritaniAssassinateState(initialized);
return initialized;
@@ -21204,6 +21210,13 @@ function settleSukRescue(g: Game, option: SukRescueOption, automatic: boolean) {
const player = getPlayer(g, pending.player);
requireRule(pending.losses && pending.physical === sukPhysicalSignature(player),
'The Suk Graduate rescue no longer has its committed physical counters.');
const sardaukar = g.nexusSardaukarHistory?.find(record =>
record.receipt.battle === pending.event && record.receipt.owner === pending.player);
if (sardaukar?.casualties) requireRule(sardaukar.casualties.outcome === 'pending' &&
JSON.stringify(pending.commitment) === JSON.stringify({forces:sardaukar.casualties.forces,
dial:sardaukar.casualties.dial,support:sardaukar.casualties.support,options:sardaukar.casualties.options}) &&
sardaukar.casualties.options.some(choice => JSON.stringify(choice) === JSON.stringify(pending.losses)),
'The Suk rescue lost its original Nexus Sardaukar casualty allocation.');
const quote = quoteSukRescue(pending.skill, pending.pool, pending.losses, option, pending.eliteOrigins);
for (const group of quote.removed) {
player.forces[group.key] -= group.normal + group.elite;
@@ -21220,6 +21233,10 @@ function settleSukRescue(g: Game, option: SukRescueOption, automatic: boolean) {
player.elites.reserves += quote.reserves.elite;
player.elites.tanks += quote.tanks.elite;
}
if (sardaukar?.casualties) {
sardaukar.casualties.outcome = 'complete';
sardaukar.signature = nexusSardaukarSignature(sardaukar);
}
g.pendingSukRescue = null;
g.lastBattleContext!.sukRescue!.completed = true;
log(g, `${player.name}'s Suk Graduate saved ${option.normal} ordinary and ${option.elite} elite forces: ${option.kept ? `1 remained in sector ${splitLocation(option.kept.key).sector}` : 'none remained in the battle territory'}, ${quote.reserves.normal + quote.reserves.elite} returned to reserves, and ${quote.tanks.normal + quote.tanks.elite} casualties went to the Tanks.${automatic ? ' The only legal rescue was applied automatically.' : ''}`, { faction: player.faction, name: 'Suk Graduate rescue' });
+29 -1
View File
@@ -9,6 +9,8 @@ export type LeaderSkillProfile = {
nexusCards?: unknown;
discoveries?: unknown;
discoveryEnabled?: unknown;
discoveryStash?: unknown;
greatMaker?: unknown;
strongholdCards?: unknown;
techTokens?: unknown;
ecazTreachery?: unknown;
@@ -22,6 +24,8 @@ export type LeaderSkillProfile = {
nexusIxianReplacementPreview?: unknown;
nexusIxianBetrayalPreview?: unknown;
nexusHarkonnenBetrayalPreview?: unknown;
mentatQuestionPreview?: unknown;
spiceBankerIncomePreview?: unknown;
};
export function noOtherLeaderSkillModules(game: LeaderSkillProfile): boolean {
@@ -29,6 +33,29 @@ export function noOtherLeaderSkillModules(game: LeaderSkillProfile): boolean {
!game.discoveryEnabled && !game.strongholdCards && !game.techTokens && !game.ecazTreachery;
}
/** Fresh classic Nexus/Skills only; other module and native compositions stay separate. */
export function classicNexusLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
const players = game.players;
if (!game.nexusCards || typeof game.advanced !== 'boolean' || game.expansions.length ||
!players || players.length < 2 || players.length > 6 ||
game.homeworlds || game.discoveries || game.discoveryEnabled ||
game.discoveryStash || game.greatMaker || game.strongholdCards || game.techTokens ||
game.ecazTreachery || game.semutaPreview || game.moritaniAssassinatePreview ||
game.advancedPreview || game.kullPreview || game.nexusKullPreview ||
game.guildBetrayalPreview || game.richeseBetrayalPreview ||
game.nexusIxianReplacementPreview || game.nexusIxianBetrayalPreview ||
game.nexusHarkonnenBetrayalPreview || game.mentatQuestionPreview ||
game.spiceBankerIncomePreview) return false;
for (let i = 0; i < players.length; i++) {
for (let j = 0; j < i; j++) if (players[j].faction === players[i].faction) return false;
let classic = false;
for (const faction of FACTIONS)
if (faction.id === players[i].faction) { classic = faction.expansion === 'base'; break; }
if (!classic) return false;
}
return true;
}
/** E1/E2 native skills may add Tech and Advanced Strongholds; original roster/decks remain. */
function nativeLeaderSkillModulesSupported(game: LeaderSkillProfile): boolean {
if (noOtherLeaderSkillModules(game)) return true;
@@ -275,5 +302,6 @@ export function nativeTechLeaderSkillsProfile(game: LeaderSkillProfile): boolean
export function ordinaryLeaderSkillModeSupported(game: LeaderSkillProfile): boolean {
return (noOtherLeaderSkillModules(game) &&
(!game.expansions.length || nativeExpansionLeaderSkillsProfile(game))) ||
classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game) || strongholdLeaderSkillsProfile(game);
classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game) ||
strongholdLeaderSkillsProfile(game) || classicNexusLeaderSkillsProfile(game);
}
+2 -1
View File
@@ -1,6 +1,7 @@
import type { Game, Player } from './engine';
import { eliteRevivalRemaining } from './revival';
import { classicNexusModulesProfile } from './nexus-module-profile';
import { classicNexusLeaderSkillsProfile } from './leader-skill-profile';
export const EMPEROR_NEXUS_REVIVALS = 3;
export type EmperorNexusPools = {
@@ -65,7 +66,7 @@ export function emperorNexusModeSupported(g: Game): boolean {
),
) &&
!g.homeworlds &&
!g.leaderSkills &&
(!g.leaderSkills || classicNexusLeaderSkillsProfile(g)) &&
!g.discoveryEnabled &&
!g.discoveries &&
!g.discoveryStash &&
+2 -1
View File
@@ -1,6 +1,7 @@
import type { Game, Player } from './engine';
import { eliteRevivalRemaining, forceRevivalLimit, freeRevivalRate } from './revival';
import { classicNexusModulesProfile } from './nexus-module-profile';
import { classicNexusLeaderSkillsProfile } from './leader-skill-profile';
export const FREMEN_NEXUS_FREE_FORCES = 3;
type RevivalPools = { reserves: number; tanks: number; eliteReserves: number; eliteTanks: number;
@@ -39,7 +40,7 @@ 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 (!classicNexusModulesProfile(g) && (g.expansions.length || g.homeworlds || g.leaderSkills || g.discoveryEnabled ||
else if (!classicNexusModulesProfile(g) && !classicNexusLeaderSkillsProfile(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))))
+4 -1
View File
@@ -4367,6 +4367,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'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.',
'The same fresh local Nexus entry also composes Tech and/or Advanced Strongholds with paired Ixians + Tleilaxu OR CHOAM + Richese and classic opponents, one original family deck and no Skills. Tech needs three through six seats; Strongholds needs two through six Advanced seats. Real closing draws, native typed forces, physical Face Dancers and No-Fields, original payments/rewards and held effects remain. Mixed families, E3, Homeworlds, unrelated previews, public starts and unresolved rulings are not opened.',
'The separate fresh local leader-skills --nexus-cards entry combines classic base33/all14 Skills/all12 Nexus in Basic or Advanced, two through six seats, without other overlays or public starts. Original starting hands and skill offers are continued, not redealt. Actual closing Nexus draws remain required. Extra/ordinary free-three returns, bank purchase and committed Traitor Deck exchanges preserve skill custody. Native Emperor Cunning composes ordinary skill posture, bonuses and Suk rescue without changing physical starred inventory or repeating support.',
'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.',
@@ -4417,7 +4418,7 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area: 'Player controls', status: 'Partial', detail: 'Private lifecycle, inspections, exchanges, Tleilaxu refresh, Ixian activation, advisor selection, temporary Sardaukar and CHOAM effects, Moritani Betrayal, Ecaz Betrayal and owner-only Secret Ally inquiry, native Duke Cunning, Richese shipment and bounded auction Betrayal, Emperor purchase and Fremen Cunning controls are connected. Remaining effect controls are unfinished.', evidence: ['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/nexus-ecaz-duke.tsx', 'components/nexus-richese-betrayal.tsx', 'components/game-table.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All four profiles choose legal actions through private offers, including Moritani/Ecaz Betrayal, Ecaz Secret Ally inquiry and native Duke Cunning, Fremen Cunning, Emperor purchase and bounded Richese auction Betrayal. Complete module strategy and calibration remain unfinished.', evidence: ['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/nexus-ecaz-duke-options.ts', 'game/nexus-richese-options.ts', 'game/bot-nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Common rules, twelve printed faces and bounded runtime contracts retain explicit unresolved Ecaz Duke capture/Tanks/Ghola and inquiry audience/scope, Fremen nested Cunning, Great Maker, combined modules, fresh advisors, storm and borrowed Voice. Richese auction Betrayal has a separate cost/prepayment/privacy contract and excluded-producer boundaries. Guild Betrayal separately records full funded payment, own pre-funding, donor escrow once, the printed full-Junction override, four native adapters and neutral privacy; ordinary occupation/rounding and general response-priority questions remain unresolved.', evidence: ['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_FREMEN_RULES.md', 'docs/NEXUS_ECAZ_RULES.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_RICHESE_RULES.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md', 'docs/NEXUS_GUILD_RULES.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Physical custody, commitments, controls, legal bots, Basic/Advanced and in-memory SQLite recovery regressions include native Ecaz Duke override and private Secret Ally inquiry. A read-only local browser fixture showed the owner-only Duke choice and no rival offer; no room was written. Bounded Richese auction Betrayal additionally has local CLI/runtime, authenticated SQLite and refreshed phone Use/Pass evidence in owned disposable rooms. New Guild Betrayal verification is separately Planned pending frozen direct evidence; these earlier results do not certify it. Fresh classic and paired E1/E2 Tech/Stronghold compositions have bounded runtime cases, genuine games and original CLI/phone evidence. Full module games, exceptional Duke custody, combined interactions and deployed acceptance remain separate.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-controls.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts'] },
{ area: 'Verification', status: 'Partial', detail: 'Physical custody, commitments, controls, legal bots, Basic/Advanced and in-memory SQLite recovery regressions include native Ecaz Duke override and private Secret Ally inquiry. A read-only local browser fixture showed the owner-only Duke choice and no rival offer; no room was written. Bounded Richese auction Betrayal additionally has local CLI/runtime, authenticated SQLite and refreshed phone Use/Pass evidence in owned disposable rooms. New Guild Betrayal verification is separately Planned pending frozen direct evidence; these earlier results do not certify it. Fresh classic and paired E1/E2 Tech/Stronghold compositions have bounded runtime cases, genuine games and original CLI/phone evidence. Classic all14 Skills additionally has actual free-return/purchase/exchange and Cunning/Suk lifecycle programs, original phone controls and genuine2..6-seat games. Full module games, exceptional Duke custody, combined interactions and deployed acceptance remain separate.', evidence: ['tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-controls.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts', 'tests/classic-nexus-skills-payments-runtime.test.ts', 'tests/classic-nexus-skills-battles-runtime.test.ts'] },
],
},
{
@@ -4604,6 +4605,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Fresh Advanced profiles compose native Ixians, Tleilaxu, CHOAM and/or Richese with classic opponents and distinct required family decks. All fourteen skills and native starts remain. Auditor stays unskilled; cyborg casualties, original CHOAM support and Richese capped marker revelation remain physical. Foreign revival creates no new skill. Native Ixian Technology on Richese lots, own mixed No-Field, captured replacement, Advanced Atreides Suk/KH and unrelated overlays retain their guards.',
'Fresh three-through-six-seat Basic/Advanced development tables may compose all fourteen Skills with original Tech Tokens: classic factions, supported E1/E2 native families, or standalone Ecaz/Moritani with the exact ecaz deck and original roster exclusions. Starting cards/cache, skill/Traitor choices and printed/first-Storm assignment stay genuine. Existing leader-skills/banker-income entry preserves unused selected tokens; mixed E3 families, other overlays, public activation and saved-game conversion remain separate.',
'Fresh Advanced classic or supported native two-through-six-seat skill tables may retain six original Stronghold Cards, with Tech at three or more seats. Original end-Mentat/local held effects remain. Arrakeen changes actual payer cost, not rescue or Banker eligibility; native CHOAM income retains bank support. Physical defenses, normal disciplines, holder ties and original dial/Worthless earnings remain. Standalone Ecaz Duke and Moritani assassination compose original rules; mixed E3, allied Occupy skills, copied Diplomat defenses, active skill/Stone and other guards stay separate.',
'Fresh classic Leader Skills may explicitly add Nexus alone through the local leader-skills --nexus-cards entry, Basic/Advanced two through six players. Starting hands can already be dealt before skill choice; saved offers remain authoritative. Original closing Nexus draws, free-return ledgers, bank purchase and Traitor Deck declarations stay distinct from physical training. Native expansions, Tech/Strongholds/Homeworlds, Banker/Mentat previews, borrowed Smuggler shipment arithmetic and other unresolved interactions remain separate.',
'With this optional module, starting Treachery Cards are dealt after prediction and before skill assignment. Every faction privately receives two skills, keeps one, returns the other to the shuffled deck, and assigns its chosen card to an eligible leader. Skill and leader become public before traitor selection.',
'Before Battle Plans, leave the skilled leader face up to keep its normal effect, or move it behind the shield for possible use. A face-up leader cannot be selected for the plan. Concealing it while choosing another leader gives neither effect. A leader that must be used because no alternative remains moves behind the shield automatically.',
'Warmaster, Master of Assassins, Swordmaster of Ginaz, Killer Medic and Prana-Bindu Adept are connected. They give another leader +1 for the matching physical card role, or the surviving assigned leader +3. These bonuses do not stack to +4. They change battle strength, not the printed disc value or leader bounty.',
@@ -4611,6 +4613,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'When reviving an own leader without an assigned skill, decide whether to draw before seeing two private cards. Once drawn, keep one and assign it to that revived leader. Replacement while an earlier skill is captured remains a separate unresolved case.',
'Planetologist chooses either one extra movement range (maximum three) or forces from two territories to one destination at normal range. These alternatives do not stack. The actual skilled leader can use an eligible green Special in the weapon slot for +2 if it survives; discard that Special after battle without activating its ordinary effect. Mixed advisor/fighter entry into an enemy-only territory and combined expansion modules remain unfinished.',
'Suk Graduate returns one selected casualty to reserves while its leader stays face up, or lets its surviving battle leader save up to three: one stays in its original sector and the rest return to reserves. The effects do not stack. Advanced Atreides awaits the Kwisatz Haderach loss-count ruling; combined modules remain unfinished.',
'In the supported classic Nexus/Skills composition, Emperor Cunning counts five actual ordinary counters as temporary Sardaukar. Normal or trained Suk rescue still saves physical ordinary casualties. The original casualty allocation remains pending while the rescue choice is open; physical counters move only when rescue settles. Original support is already paid and is not refunded or charged twice. A normal save returns one to reserves; a trained save keeps one and returns the rest. Actual starred reserves and Tanks remain unchanged.',
'A surviving selected Mentat adds two battle strength. A surviving selected Bureaucrat subtracts one from the opponent’s total per distinct stronghold occupied by that opponent; advisors do not count. Printed disc values and bounties stay unchanged.',
'A living native Sandmaster may collect one spice per entered territory along a declared legal ground route. Choose each collection separately; canceled moves earn nothing. The initial source and a sector-only reposition earn nothing. Separate movements have separate collection opportunities. Native Fremen worm rides may collect once at the destination before faction arrival reactions, without a ground route or normal movement use. Multiple piles and other special relocation remain unfinished.',
'A surviving skilled Sandmaster victory automatically adds three board spice to an existing pile in the battle territory, even with no surviving forces. Multiple piles await a placement ruling.',
@@ -0,0 +1,319 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { maxCombatDial } from '../game/combat';
import { nexusInventory } from './fixture-nexus-cards';
import {
advanceClassicNexusSkillsBattle, beginClassicNexusSkillsCunning,
classicNexusSkillsBattlePlayer as player, createClassicNexusSkillsBattleFixture,
finishClassicNexusSkillsBattle, prepareClassicNexusSkillsBattle,
quoteClassicNexusSkillsBattle, revealClassicNexusSkillsBattle, stepClassicNexusSkillsBattle,
type ClassicNexusSkillsBattleFixture,
} from './fixture-classic-nexus-skills-battles';
function custody(game: Game): void {
nexusInventory(game);
const skills = game.leaderSkills!;
assert.deepEqual([...skills.deck, ...skills.assignments.map(assignment => assignment.skill),
...Object.values(skills.offers).flatMap(offer => offer.cards)].sort(), LEADER_SKILL_CARDS.map(card => card.id).sort());
for (const seat of game.players) if (seat.elites) {
assert.equal(seat.elites.reserves + seat.elites.tanks + Object.values(seat.elites.forces).reduce((sum, count) => sum + count, 0),
seat.faction === 'fremen' ? 3 : 5);
for (const [key, count] of Object.entries(seat.elites.forces)) assert.ok(count <= (seat.forces[key] ?? 0));
}
}
function reject(game: Game, actor: string, action: Action): void {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
function moneyAtCleanup(revealed: Game, cleanup: Game): void {
const quote = quoteClassicNexusSkillsBattle(revealed);
assert.equal(cleanup.decision?.kind, 'battleCards');
assert.equal(cleanup.lastBattleContext!.winner, quote.winner);
for (const seat of revealed.players) {
const payment = quote.payments.filter(payment => payment.player === seat.id).reduce((sum, payment) => sum + payment.ownPayment, 0);
const bounty = quote.bounty?.player === seat.id ? quote.bounty.amount : 0;
assert.equal(player(cleanup, seat.id).spice, seat.spice - payment + bounty,
'Original battle payment and printed-disc bounty settle once before Collection');
}
assert.deepEqual(quote.strongholdIncome, []);
custody(cleanup);
}
function lossWindow(fixture: ClassicNexusSkillsBattleFixture): { revealed: Game; game: Game } {
const revealed = revealClassicNexusSkillsBattle(fixture, { cunning: true });
const game = finishClassicNexusSkillsBattle(revealed, { stop: 'losses' });
assert.equal(game.decision?.kind, 'battleLosses');
return { revealed, game };
}
for (const advanced of [false, true]) for (const count of [3, 4, 5, 6]) {
void test(`${advanced ? 'Advanced' : 'Basic'} ${count}-seat original setup, Mentat, reciprocal alliance and closing draw preserve physical sources and actor IDs`, () => {
const fixture = createClassicNexusSkillsBattleFixture({ advanced,
seatIds: Array.from({ length: count }, (_, index) => `original-${advanced}-${count}-${index}`) });
for (const game of [fixture.initial, fixture.afterSetup, fixture.afterFirstMentat, fixture.beforeDraw, fixture.afterDraw, fixture.beforeBattle]) custody(game);
const mentat = stepClassicNexusSkillsBattle(fixture.beforeFirstMentat, fixture.firstMentatStep);
assert.equal(mentat.turn, 2);
assert.deepEqual(mentat, fixture.afterFirstMentat);
let alliance = fixture.beforeAlliance;
for (const action of fixture.allianceActions) alliance = stepClassicNexusSkillsBattle(alliance, action);
assert.equal(player(alliance, fixture.observer).ally, fixture.partner);
assert.equal(player(alliance, fixture.partner).ally, fixture.observer);
assert.equal(player(fixture.beforeDraw, fixture.owner).ally, null);
assert.ok(fixture.beforeDraw.nexusCards!.phase!.eligible.includes(fixture.owner));
assert.equal(fixture.beforeDraw.nexusCards!.cards!.hands[fixture.owner], null);
const drawn = stepClassicNexusSkillsBattle(fixture.beforeDraw, fixture.drawAction);
assert.deepEqual(drawn, fixture.afterDraw);
assert.equal(drawn.nexusCards!.cards!.hands[fixture.owner], 'emperor');
assert.equal(drawn.nexusCards!.cards!.deck.includes('emperor'), false);
assert.deepEqual(fixture.beforeBattle.players.map(seat => seat.id), fixture.initial.players.map(seat => seat.id));
assert.deepEqual([...fixture.afterSetup.spiceDeck, ...fixture.afterSetup.spiceDiscard.flat()].map(card => JSON.stringify(card)).sort(),
[...fixture.beforeBattle.spiceDeck, ...fixture.beforeBattle.spiceDiscard.flat()].map(card => JSON.stringify(card)).sort());
assert.deepEqual([...fixture.afterSetup.traitorReserve!, ...fixture.afterSetup.players.flatMap(seat => seat.traitors)].sort(),
[...fixture.beforeBattle.traitorReserve!, ...fixture.beforeBattle.players.flatMap(seat => seat.traitors)].sort());
});
}
void test('original undealt authenticated CLI setup is consumed without replacing saved offers or actor identities', () => {
const original = createClassicNexusSkillsBattleFixture({ seatIds: ['human-emperor', 'human-guild', 'human-atreides', 'human-fremen'] }).initial;
const before = structuredClone(original);
const fixture = createClassicNexusSkillsBattleFixture({ initial: original, skill: 'warmaster', ownerDial: 4, support: 4 });
assert.deepEqual(original, before);
assert.deepEqual(fixture.initial, original);
assert.equal(fixture.owner, 'human-emperor'); assert.equal(fixture.opponent, 'human-guild');
const revealed = revealClassicNexusSkillsBattle(fixture);
const quote = quoteClassicNexusSkillsBattle(revealed);
assert.equal(quote.winner, fixture.owner);
assert.equal(revealed.nexusCards!.cards!.hands[fixture.owner], 'emperor');
moneyAtCleanup(revealed, finishClassicNexusSkillsBattle(revealed, { stop: 'cleanup' }));
});
for (const band of ['normal', 'skilled'] as const) {
void test(`native Advanced Cunning plus ${band} Warmaster counts five ordinary counters temporarily, never creates elite Tanks or discounted support`, () => {
const fixture = createClassicNexusSkillsBattleFixture({ skill: 'warmaster', band, ownerWeapon: 'projectile', ownerDefense: 'worthless' });
const original = structuredClone(player(fixture.game, fixture.owner));
const revealed = revealClassicNexusSkillsBattle(fixture, { cunning: true });
const quote = quoteClassicNexusSkillsBattle(revealed);
const ownForces = viewGame(revealed, fixture.owner).battle!.ownForces!;
assert.equal(ownForces.normal, 7); assert.equal(ownForces.elite, 0); assert.equal(ownForces.temporaryElite, 5);
assert.equal(maxCombatDial(ownForces), 12);
assert.equal(quote.winner, fixture.owner);
const bonus = revealed.battle!.attacker === fixture.owner ? quote.leaderSkillBonuses.attacker : quote.leaderSkillBonuses.defender;
assert.deepEqual(bonus, { bonus: band === 'skilled' ? 3 : 1,
applied: [{ skill: 'warmaster', amount: band === 'skilled' ? 3 : 1, mode: band }] });
const ownScore = revealed.battle!.attacker === fixture.owner ? quote.scores!.attacker : quote.scores!.defender;
const disc = player(revealed, fixture.owner).leaders.find(leader => leader.id === revealed.battle!.plans[fixture.owner].leader)!;
assert.equal(ownScore, 6 + disc.strength + bonus.bonus);
const opposingDisc = player(revealed, fixture.opponent).leaders.find(leader => leader.id === revealed.battle!.plans[fixture.opponent].leader)!;
assert.equal(quote.bounty!.amount, opposingDisc.strength, 'Leader Skill strength is not part of printed-disc bounty');
assert.equal(quote.bounty!.player, fixture.owner);
const payment = quote.payments.find(payment => payment.player === fixture.owner)!;
assert.equal(payment.ownPayment, 3); assert.equal(payment.bankSupport, 0); assert.equal(payment.allyPayment, 0);
assert.deepEqual(quote.casualties!.options.map(loss => [loss.normal, loss.elite, loss.paidNormal, loss.paidElite]),
[[3, 0, 3, 0], [4, 0, 3, 0], [5, 0, 3, 0]]);
let game = finishClassicNexusSkillsBattle(revealed, { stop: 'losses' });
game = applyAction(game, fixture.owner, { type: 'decision', choice: 2 });
assert.equal(player(game, fixture.owner).forces[fixture.key], 2);
assert.equal(player(game, fixture.owner).tanks, original.tanks + 5);
assert.deepEqual(player(game, fixture.owner).elites, original.elites);
assert.equal(game.nexusSardaukarHistory![0].casualties!.outcome, 'complete');
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'emperor').length, 1);
moneyAtCleanup(revealed, game);
const finished = finishClassicNexusSkillsBattle(game);
assert.deepEqual(player(finished, fixture.owner).elites, original.elites);
assert.equal(viewGame(finished, fixture.owner).nexusSardaukar!.active, false);
custody(finished);
});
}
for (const band of ['normal', 'skilled'] as const) {
void test(`the original ${band} Warmaster modifier decides a native Cunning battle rather than merely decorating a larger dial`, () => {
const fixture = createClassicNexusSkillsBattleFixture({ skill: 'warmaster', band, ownerDial: 0, support: 0,
opponentDial: band === 'normal' ? 0.5 : 5, opponentSupport: band === 'normal' ? 0 : 5 });
const revealed = revealClassicNexusSkillsBattle(fixture, { cunning: true });
const quote = quoteClassicNexusSkillsBattle(revealed);
const ownScore = revealed.battle!.attacker === fixture.owner ? quote.scores!.attacker : quote.scores!.defender;
const opposingScore = revealed.battle!.attacker === fixture.owner ? quote.scores!.defender : quote.scores!.attacker;
const disc = player(revealed, fixture.owner).leaders.find(leader => leader.id === revealed.battle!.plans[fixture.owner].leader)!;
assert.ok(disc.strength < opposingScore);
assert.ok(ownScore > opposingScore);
assert.equal(quote.winner, fixture.owner);
assert.equal(quote.payments.find(payment => payment.player === fixture.owner)!.ownPayment, 0);
assert.equal(quote.casualties!.forces.temporaryElite, 5);
assert.equal(quote.casualties!.options[0].normal, 0);
moneyAtCleanup(revealed, finishClassicNexusSkillsBattle(revealed, { stop: 'cleanup' }));
});
}
void test('a killed skilled Warmaster loses the actual physical skill and pays only printed-disc bounty', () => {
const fixture = createClassicNexusSkillsBattleFixture({ skill: 'warmaster', opponentWeapon: 'projectile' });
const revealed = revealClassicNexusSkillsBattle(fixture, { cunning: true });
const quote = quoteClassicNexusSkillsBattle(revealed);
assert.equal(quote.winner, fixture.owner, 'Actual dial six exceeds the opposing surviving printed disc even when the skilled leader dies');
const bonus = revealed.battle!.attacker === fixture.owner ? quote.leaderSkillBonuses.attacker : quote.leaderSkillBonuses.defender;
assert.equal(bonus.bonus, 0);
const disc = player(revealed, fixture.owner).leaders.find(leader => leader.id === fixture.trainer)!;
assert.equal(quote.bounty!.amount, disc.strength);
assert.equal(quote.bounty!.player, fixture.owner);
const cleanup = finishClassicNexusSkillsBattle(revealed, { stop: 'cleanup' });
assert.equal(player(cleanup, fixture.owner).leaders.find(leader => leader.id === fixture.trainer)!.dead, true);
assert.equal(cleanup.leaderSkills!.assignments.some(assignment => assignment.leader === fixture.trainer), false);
assert.equal(cleanup.leaderSkills!.deck.filter(skill => skill === 'warmaster').length, 1);
moneyAtCleanup(revealed, cleanup);
});
for (const band of ['normal', 'skilled'] as const) {
void test(`actual ${band} native Emperor Suk rescues Cunning ordinary casualties before winner cleanup, paying original support once`, () => {
const fixture = createClassicNexusSkillsBattleFixture({ skill: 'suk-graduate', band, ownerDefense: 'shield' });
const original = structuredClone(player(fixture.game, fixture.owner));
const { revealed, game: losses } = lossWindow(fixture);
const choice = band === 'normal' ? 1 : 2;
const physicalLoss = band === 'normal' ? 4 : 5;
const saved = band === 'normal' ? 1 : 3;
assert.ok(losses.decision?.kind === 'battleLosses');
assert.equal(losses.decision.options[choice].normal, physicalLoss);
assert.equal(losses.decision.options[choice].elite, 0);
reject(losses, fixture.opponent, { type: 'decision', choice });
let game = applyAction(losses, fixture.owner, { type: 'decision', choice });
if (band === 'skilled') {
const decision = game.decision; assert.ok(decision?.kind === 'sukRescue');
assert.equal(game.pendingSukRescue!.commitment.forces.temporaryElite, 5);
assert.deepEqual(game.pendingSukRescue!.pool, [{ key: fixture.key, normal: 7, elite: 0 }]);
assert.deepEqual(game.pendingSukRescue!.losses, losses.decision.options[choice]);
assert.equal(game.nexusSardaukarHistory![0].casualties!.outcome, 'pending');
assert.equal(player(game, fixture.owner).tanks, original.tanks);
const rescue = decision.options.findIndex(option => option.normal === 3 && option.elite === 0 && option.kept?.key === fixture.key && option.kept.kind === 'normal');
assert.ok(rescue >= 0);
const action: Action = { type: 'decision', event: decision.event, choice: rescue };
reject(game, fixture.opponent, action);
reject(game, fixture.owner, { ...action, event: `${decision.event}-stale` });
game = applyAction(game, fixture.owner, action);
reject(game, fixture.owner, action);
}
assert.equal(player(game, fixture.owner).forces[fixture.key], 7 - physicalLoss + (band === 'skilled' ? 1 : 0));
assert.equal(player(game, fixture.owner).reserves, original.reserves + saved - (band === 'skilled' ? 1 : 0));
assert.equal(player(game, fixture.owner).tanks, original.tanks + physicalLoss - saved);
assert.equal(player(game, fixture.owner).battleLosses, original.battleLosses + physicalLoss - saved);
assert.deepEqual(player(game, fixture.owner).elites, original.elites);
assert.equal(game.pendingSukRescue, null);
assert.equal(game.lastBattleContext!.sukRescue!.completed, true);
assert.equal(game.nexusSardaukarHistory![0].casualties!.outcome, 'complete');
assert.equal(player(game, fixture.owner).spice, player(revealed, fixture.owner).spice - 3);
moneyAtCleanup(revealed, game);
custody(finishClassicNexusSkillsBattle(game));
});
}
void test('a killed skilled native Suk disc creates no rescue entitlement and returns its singleton skill to the original deck', () => {
const fixture = createClassicNexusSkillsBattleFixture({ skill: 'suk-graduate', opponentWeapon: 'projectile', ownerWeapon: 'worthless' });
const revealed = revealClassicNexusSkillsBattle(fixture, { cunning: true });
assert.equal(quoteClassicNexusSkillsBattle(revealed).sukGraduate, undefined);
const cleanup = finishClassicNexusSkillsBattle(revealed, { stop: 'cleanup' });
assert.equal(cleanup.pendingSukRescue ?? null, null);
assert.equal(player(cleanup, fixture.owner).leaders.find(leader => leader.id === fixture.trainer)!.dead, true);
assert.equal(cleanup.leaderSkills!.deck.filter(skill => skill === 'suk-graduate').length, 1);
assert.equal(player(cleanup, fixture.owner).tanks, player(revealed, fixture.owner).tanks + 3);
moneyAtCleanup(revealed, cleanup);
});
void test('original physical Karama cancels Cunning, spends each singleton once, and leaves a legal ordinary Warmaster plan', () => {
const fixture = createClassicNexusSkillsBattleFixture({ counter: true, ownerDial: 4, support: 4 });
const prepared = prepareClassicNexusSkillsBattle(fixture);
const elites = structuredClone(player(prepared, fixture.owner).elites);
let game = beginClassicNexusSkillsCunning(fixture, prepared);
assert.equal(game.response?.kind, 'nexusSardaukar');
game = applyAction(game, fixture.opponent, { type: 'card', card: fixture.counter!, mode: 'cancel' });
game = advanceClassicNexusSkillsBattle(game, state => !state.response && !state.decision && !state.phaseOpening);
assert.equal(game.nexusSardaukarLast!.stage, 'canceled');
assert.equal(viewGame(game, fixture.owner).battle!.ownForces!.temporaryElite ?? 0, 0);
assert.deepEqual(player(game, fixture.owner).elites, elites);
assert.equal(game.discard.filter(card => card.id === fixture.counter).length, 1);
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'emperor').length, 1);
const revealed = revealClassicNexusSkillsBattle(fixture, { state: game });
assert.equal(quoteClassicNexusSkillsBattle(revealed).payments.find(payment => payment.player === fixture.owner)!.ownPayment, 4);
moneyAtCleanup(revealed, finishClassicNexusSkillsBattle(revealed, { stop: 'cleanup' }));
});
void test('sealing the original Advanced plan blocks late Cunning without spending its held Emperor card', () => {
const fixture = createClassicNexusSkillsBattleFixture({ ownerDial: 4, support: 4 });
let game = prepareClassicNexusSkillsBattle(fixture);
const event = viewGame(game, fixture.owner).nexusSardaukar!.offer!.event;
game = applyAction(game, fixture.owner, fixture.planActions[0].action);
assert.match(viewGame(game, fixture.owner).nexusSardaukar!.offer!.blocked!, /committed/);
reject(game, fixture.owner, { type: 'nexusSardaukar', event });
assert.equal(game.nexusCards!.cards!.hands[fixture.owner], 'emperor');
game = applyAction(game, fixture.opponent, fixture.planActions[1].action);
moneyAtCleanup(game, finishClassicNexusSkillsBattle(game, { stop: 'cleanup' }));
});
void test('Basic retains the explicit blocked Sardaukar offer while the original skilled Warmaster plan legally wins without doubled forces or spice support', () => {
const fixture = createClassicNexusSkillsBattleFixture({ advanced: false });
const prepared = prepareClassicNexusSkillsBattle(fixture);
const offer = viewGame(prepared, fixture.owner).nexusSardaukar!.offer!;
assert.match(offer.blocked!, /Advanced advantage/);
reject(prepared, fixture.owner, { type: 'nexusSardaukar', event: offer.event });
assert.equal(maxCombatDial(viewGame(prepared, fixture.owner).battle!.ownForces!), 7);
const revealed = revealClassicNexusSkillsBattle(fixture);
const quote = quoteClassicNexusSkillsBattle(revealed);
assert.deepEqual(quote.payments, []);
assert.equal(quote.winner, fixture.owner);
assert.equal(viewGame(revealed, fixture.owner).battle!.ownForces!.temporaryElite ?? 0, 0);
assert.equal(quote.casualties, null);
assert.equal(quote.basicWinnerLosses, 6);
const cleanup = finishClassicNexusSkillsBattle(revealed, { stop: 'cleanup' });
assert.equal(player(cleanup, fixture.owner).tanks, player(revealed, fixture.owner).tanks + 6);
assert.equal(cleanup.nexusCards!.cards!.hands[fixture.owner], 'emperor');
moneyAtCleanup(revealed, cleanup);
});
void test('completed Cunning expires for the actual second battle in the same turn and cannot reuse its spent source', () => {
const fixture = createClassicNexusSkillsBattleFixture({ secondBattle: true });
const revealed = revealClassicNexusSkillsBattle(fixture, { cunning: true });
const oldEvent = revealed.nexusSardaukarLast!.event;
let game = finishClassicNexusSkillsBattle(revealed);
assert.equal(game.turn, 2); assert.equal(game.phase, 6);
const actor = game.active!;
game = applyAction(game, actor, { type: 'chooseBattle', territory: 'carthag', target: actor === fixture.owner ? fixture.opponent : fixture.owner });
game = prepareClassicNexusSkillsBattle(fixture, game);
assert.equal(viewGame(game, fixture.owner).battle!.ownForces!.normal, 1);
assert.equal(viewGame(game, fixture.owner).battle!.ownForces!.temporaryElite ?? 0, 0);
assert.equal(maxCombatDial(viewGame(game, fixture.owner).battle!.ownForces!), 1);
assert.equal(viewGame(game, fixture.owner).nexusSardaukar!.active, false);
reject(game, fixture.owner, { type: 'nexusSardaukar', event: oldEvent });
custody(game);
});
void test('real END Mentat and next Storm do not reactivate historical temporary roles', () => {
const fixture = createClassicNexusSkillsBattleFixture();
const revealed = revealClassicNexusSkillsBattle(fixture, { cunning: true });
const oldEvent = revealed.nexusSardaukarLast!.event;
let game = finishClassicNexusSkillsBattle(revealed);
game = advanceClassicNexusSkillsBattle(game, state => state.turn === 3 && state.phase === 1 && !state.phaseOpening && !state.response && !state.decision);
assert.equal(viewGame(game, fixture.owner).nexusSardaukar!.active, false);
assert.equal(game.nexusSardaukarHistory![0].receipt.turn, 2);
assert.equal(game.nexusSardaukarHistory![0].casualties!.outcome, 'complete');
reject(game, fixture.owner, { type: 'nexusSardaukar', event: oldEvent });
custody(game);
});
void test('minimal legal Easy bot path resolves the real typed Suk rescue before human winner-card cleanup', () => {
const fixture = createClassicNexusSkillsBattleFixture({ skill: 'suk-graduate', ownerDefense: 'shield' });
const { revealed, game: losses } = lossWindow(fixture);
let game = applyAction(losses, fixture.owner, { type: 'decision', choice: 2 });
assert.equal(game.decision?.kind, 'sukRescue');
const view = viewGame(game, fixture.owner);
view.players.find(seat => seat.id === fixture.owner)!.bot = 'Easy';
const action = botActions(view).find(action => action.type === 'decision');
assert.ok(action && typeof action.choice === 'number');
assert.ok(game.decision?.kind === 'sukRescue');
assert.equal(game.decision.options[action.choice].normal, 3);
assert.equal(game.decision.options[action.choice].elite, 0);
game = applyAction(game, fixture.owner, action);
assert.equal(player(game, fixture.owner).forces[fixture.key], 3);
assert.equal(player(game, fixture.owner).tanks, player(revealed, fixture.owner).tanks + 2);
assert.equal(game.lastBattleContext!.sukRescue!.completed, true);
moneyAtCleanup(revealed, game);
custody(finishClassicNexusSkillsBattle(game));
});
@@ -0,0 +1,267 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { forceRevivalQuote, forceRevivalRemaining } from '../game/revival';
import {
advanceClassicNexusSkillsPayments, advanceClassicNexusSkillsPaymentsStep,
advanceClassicNexusSkillsPaymentsToPhase, classicNexusSkillsPaymentsInventory,
classicNexusSkillsPaymentsPlayer, createClassicNexusSkillsPaymentsFixture,
finishClassicNexusSkillsPaymentsFixture, initializeClassicNexusSkillsPaymentsSetup,
payClassicNexusSkillsPaymentsFixture,
} from './fixture-classic-nexus-skills-payments';
function rejected(game: Game, actor: string, action: Action): void {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before, 'Invalid actions cannot mutate physical custody or any original accounting');
}
const wallets = (game: Game) => game.players.map(({ id, spice }) => ({ id, spice }));
function sameSkillCustody(before: Game, after: Game): void {
assert.deepEqual(after.leaderSkills, before.leaderSkills, 'Nexus use cannot redraw, detach or transfer an ordinary physical skill');
for (const player of before.players)
assert.deepEqual(viewGame(after, player.id).leaderSkills!.assignments,
viewGame(before, player.id).leaderSkills!.assignments,
'Consumer-visible assignment, controller and face-up custody must remain unchanged');
}
for (const advanced of [false, true]) {
for (const kind of ['emperor-revival', 'fremen-revival'] as const) {
void test(`${advanced ? 'Advanced' : 'Basic'} all14/${kind} returns exactly three with original quota and wallet accounting`, () => {
const f = createClassicNexusSkillsPaymentsFixture({ advanced, kind });
assert.equal(f.initial.setupStage, 'leaderSkills');
assert.equal(f.initial.leaderSkills!.assignments.length, 0);
assert.equal(f.afterSetup.leaderSkills!.assignments.length, f.afterSetup.players.length);
assert.ok(f.afterSetup.players.every(player => player.traitorChoices.length === 0));
assert.equal(f.beforeFirstMentat.turn, 1);
assert.equal(f.afterFirstMentat.turn, 2);
assert.equal(f.beforeClosingDraw.nexusCards!.phase!.stage, 'drawing');
assert.equal(f.beforeClosingDraw.nexusCards!.cards!.hands[f.actor], null);
assert.equal(f.afterClosingDraw.nexusCards!.cards!.hands[f.actor], kind === 'emperor-revival' ? 'emperor' : 'fremen');
assert.equal(f.game.phase, 4);
const before = classicNexusSkillsPaymentsPlayer(f.game, f.actor);
rejected(f.game, f.opponent, f.paymentAction);
rejected(f.game, f.actor, { ...f.paymentAction, event: 'stale' });
let paid = payClassicNexusSkillsPaymentsFixture(f);
let actor = classicNexusSkillsPaymentsPlayer(paid, f.actor);
assert.equal(actor.reserves, before.reserves + 3);
assert.equal(actor.tanks, before.tanks - 3);
assert.deepEqual(wallets(paid), wallets(f.game));
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.nexusCards!.cards!.hands[f.actor], null);
assert.equal(paid.nexusCards!.cards!.discard.filter(card => card === (kind === 'emperor-revival' ? 'emperor' : 'fremen')).length, 1);
const receipt = kind === 'emperor-revival' ? paid.nexusEmperorSecretHistory![0] : paid.nexusFremenRevivalHistory![0];
assert.equal(receipt.owner, f.actor); assert.equal(receipt.turn, 2); assert.equal(receipt.phase, 4);
assert.equal(receipt.before.tanks - receipt.after.tanks, 3);
assert.equal(receipt.before.spice, receipt.after.spice);
sameSkillCustody(f.game, paid);
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 retains its original two-free plus one-paid invoice');
paid = applyAction(paid, f.actor, { type: 'revive', amount: 3 });
actor = classicNexusSkillsPaymentsPlayer(paid, f.actor);
assert.equal(actor.reserves, before.reserves + 6);
assert.equal(actor.tanks, before.tanks - 6);
assert.equal(actor.spice, before.spice - quote.cost);
assert.equal(actor.revived, 3); assert.equal(actor.freeForcesRevived, 2);
sameSkillCustody(f.game, paid);
}
classicNexusSkillsPaymentsInventory(paid);
const atMentat = advanceClassicNexusSkillsPaymentsToPhase(paid, 8);
assert.equal(atMentat.turn, 2);
assert.equal(classicNexusSkillsPaymentsPlayer(atMentat, f.actor).revived, 3,
'Movement/Battle/Collection cannot prematurely reset the original Revival ledger');
sameSkillCustody(f.game, atMentat);
const nextTurn = advanceClassicNexusSkillsPayments(atMentat, game => game.turn === 3);
assert.equal(classicNexusSkillsPaymentsPlayer(nextTurn, f.actor).revived, 3,
'The real END Mentat does not reset force-revival accounting early');
const nextRevival = advanceClassicNexusSkillsPaymentsToPhase(nextTurn, 4);
assert.equal(classicNexusSkillsPaymentsPlayer(nextRevival, f.actor).revived, 0);
assert.equal(classicNexusSkillsPaymentsPlayer(nextRevival, f.actor).freeForcesRevived ?? 0, 0);
assert.deepEqual(nextTurn.nexusEmperorSecretHistory, paid.nexusEmperorSecretHistory);
assert.deepEqual(nextTurn.nexusFremenRevivalHistory, paid.nexusFremenRevivalHistory);
sameSkillCustody(f.game, nextTurn);
classicNexusSkillsPaymentsInventory(nextTurn);
});
}
void test(`${advanced ? 'Advanced' : 'Basic'} Fremen free-three remains early-only and both grants require an exact three`, () => {
const f = createClassicNexusSkillsPaymentsFixture({ advanced, 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);
sameSkillCustody(f.game, late);
for (const kind of ['emperor-revival', 'fremen-revival'] as const) {
const short = createClassicNexusSkillsPaymentsFixture({ advanced, kind, tanks: 2 });
rejected(short.game, short.actor, short.paymentAction);
assert.equal(classicNexusSkillsPaymentsPlayer(short.game, short.actor).tanks, 2);
classicNexusSkillsPaymentsInventory(short.game);
}
const extra = createClassicNexusSkillsPaymentsFixture({ advanced, kind: 'emperor-revival' });
let ordinary = applyAction(extra.game, extra.actor, { type: 'revive', amount: 3 });
const pools = classicNexusSkillsPaymentsPlayer(ordinary, extra.actor);
const wallet = pools.spice;
ordinary = applyAction(ordinary, extra.actor, extra.paymentAction);
const after = classicNexusSkillsPaymentsPlayer(ordinary, extra.actor);
assert.equal(after.revived, 3); assert.equal(after.freeForcesRevived, 2);
assert.equal(after.reserves, pools.reserves + 3); assert.equal(after.tanks, pools.tanks - 3);
assert.equal(after.spice, wallet, 'Emperor remains additional even after the ordinary quota is spent');
sameSkillCustody(extra.game, ordinary);
classicNexusSkillsPaymentsInventory(ordinary);
});
void test(`${advanced ? 'Advanced' : 'Basic'} normal bank auction Emperor Secret Ally retains the earned bid and delivers one original lot`, () => {
const f = createClassicNexusSkillsPaymentsFixture({ advanced, kind: 'emperor-purchase' });
const auction = f.game.auction!;
const lot = auction.cards[auction.index];
assert.equal(f.game.decision!.kind, 'auctionPayment');
assert.equal(f.game.decision!.player, f.actor);
assert.equal(auction.bidder, f.actor); assert.equal(auction.bid, 2);
assert.equal(auction.allyPayment ?? 0, 0);
const buyer = classicNexusSkillsPaymentsPlayer(f.game, f.actor);
assert.ok(buyer.spice >= auction.bid);
assert.ok(!buyer.hand.some(card => card.id === lot.id));
rejected(f.game, f.opponent, f.paymentAction);
rejected(f.game, f.actor, { ...f.paymentAction, event: 'stale' });
const botView = viewGame(f.game, f.actor);
botView.players.find(player => player.id === f.actor)!.bot = 'Hard';
const bot = botActions(botView).find(action => action.type === 'nexusEmperorPurchase');
assert.deepEqual(bot, f.paymentAction);
const paid = applyAction(f.game, f.actor, bot!);
assert.deepEqual(wallets(paid), wallets(f.game));
assert.equal(classicNexusSkillsPaymentsPlayer(paid, f.actor).hand.filter(card => card.id === lot.id).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.equal(paid.nexusEmperorPurchaseHistory!.length, 1);
assert.equal(paid.nexusCards!.cards!.discard.filter(card => card === 'emperor').length, 1);
sameSkillCustody(f.game, paid); rejected(paid, f.actor, f.paymentAction);
classicNexusSkillsPaymentsInventory(paid);
const continued = advanceClassicNexusSkillsPaymentsToPhase(paid, 4);
assert.equal(classicNexusSkillsPaymentsPlayer(continued, f.actor).hand.filter(card => card.id === lot.id).length, 1);
assert.deepEqual(continued.nexusEmperorPurchaseHistory, paid.nexusEmperorPurchaseHistory);
sameSkillCustody(f.game, continued); classicNexusSkillsPaymentsInventory(continued);
});
void test(`${advanced ? 'Advanced' : 'Basic'} original Harkonnen Cunning draws and returns physical traitors without changing trained-disc custody`, () => {
const f = createClassicNexusSkillsPaymentsFixture({ advanced, kind: 'harkonnen-cunning' });
const owner = classicNexusSkillsPaymentsPlayer(f.game, f.actor);
const top = f.game.traitorReserve![0];
const reserve = [...f.game.traitorReserve!];
assert.equal(owner.traitors.length, 4);
rejected(f.game, f.opponent, f.paymentAction);
rejected(f.game, f.actor, { ...f.paymentAction, event: 'stale' });
const view = viewGame(f.game, f.actor);
view.players.find(player => player.id === f.actor)!.bot = 'Hard';
const bot = botActions(view).find(action => action.type === 'nexusTraitorDraw');
assert.deepEqual(bot, f.paymentAction);
const drawn = applyAction(f.game, f.actor, bot!);
assert.deepEqual(classicNexusSkillsPaymentsPlayer(drawn, f.actor).traitors, [...owner.traitors, top]);
assert.deepEqual(drawn.traitorReserve, reserve.slice(1));
assert.equal(drawn.nexusCards!.cards!.hands[f.actor], null);
const pending = viewGame(drawn, f.actor).nexusTraitors!.pending!;
assert.equal(pending.count, 1);
assert.equal(pending.choices.filter(choice => choice.drawn).length, 1);
sameSkillCustody(f.game, drawn);
for (const actor of drawn.players.map(player => player.id)) rejected(drawn, actor, { type: 'ready' });
const giveBack: Action = { type: 'nexusTraitorReturn', event: pending.event, cards: [owner.traitors[0]] };
rejected(drawn, f.opponent, giveBack);
rejected(drawn, f.actor, { ...giveBack, event: 'stale' });
rejected(drawn, f.actor, { ...giveBack, cards: ['foreign-identity'] });
const returned = finishClassicNexusSkillsPaymentsFixture(f, drawn, [owner.traitors[0]]);
assert.deepEqual(classicNexusSkillsPaymentsPlayer(returned, f.actor).traitors, [...owner.traitors.slice(1), top]);
assert.equal(returned.traitorReserve!.length, reserve.length);
assert.ok(returned.traitorReserve!.includes(owner.traitors[0]));
assert.equal(returned.nexusTraitorExchanges![0].stage, 'complete');
assert.equal(returned.nexusCards!.cards!.discard.filter(card => card === 'harkonnen').length, 1);
assert.deepEqual(wallets(returned), wallets(f.game));
rejected(returned, f.actor, giveBack); rejected(returned, f.actor, f.paymentAction);
sameSkillCustody(f.game, returned); classicNexusSkillsPaymentsInventory(returned);
});
for (const declaredTraitor of ['call', 'decline'] as const) {
void test(`${advanced ? 'Advanced' : 'Basic'} Cunning preserves an earlier ${declaredTraitor} after its physical matching identity changes custody`, () => {
const f = createClassicNexusSkillsPaymentsFixture({ advanced, kind: 'harkonnen-cunning', declaredTraitor });
const identity = f.declaredIdentity!;
assert.equal(f.game.battle!.traitorCalls[f.actor], declaredTraitor === 'call');
const declaration = structuredClone(f.game.battle!.traitorDeclarations);
const drawn = payClassicNexusSkillsPaymentsFixture(f);
assert.ok(classicNexusSkillsPaymentsPlayer(drawn, f.actor).traitors.includes(identity));
const pending = viewGame(drawn, f.actor).nexusTraitors!.pending!;
const returnedIdentity = declaredTraitor === 'call' ? identity : pending.choices.find(choice => choice.id !== identity)!.id;
const returned = finishClassicNexusSkillsPaymentsFixture(f, drawn, [returnedIdentity]);
assert.equal(classicNexusSkillsPaymentsPlayer(returned, f.actor).traitors.includes(identity), declaredTraitor === 'decline');
assert.equal(returned.battle!.traitorCalls[f.actor], declaredTraitor === 'call');
assert.deepEqual(returned.battle!.traitorDeclarations, declaration);
if (declaredTraitor === 'call') assert.equal(returned.battle!.traitorDeclarations![f.actor].identity, identity);
else rejected(returned, f.actor, { type: 'traitorCall', call: true });
sameSkillCustody(f.game, returned);
const resolved = applyAction(returned, f.opponent, { type: 'traitorCall', call: false });
if (declaredTraitor === 'call') assert.equal(classicNexusSkillsPaymentsPlayer(resolved, f.opponent).tanks, 5);
else assert.equal(resolved.lastBattleContext!.result, 'normal',
'An identity drawn after declining cannot become a retroactive traitor call');
sameSkillCustody(f.game, resolved); classicNexusSkillsPaymentsInventory(resolved);
});
}
}
for (const kind of ['emperor-revival', 'fremen-revival'] as const) {
void test(`${kind} genuinely preserves Advanced native elite counters and the original one-per-turn cap`, () => {
const f = createClassicNexusSkillsPaymentsFixture({ kind, advanced: 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(player => player.id === f.actor)!.bot = 'Hard';
const bot = botActions(view).find(action => action.type === f.paymentAction.type);
assert.ok(bot, 'The newly supported original profile must offer a real legal bot action');
const paid = applyAction(f.game, f.actor, bot);
const before = classicNexusSkillsPaymentsPlayer(f.game, f.actor);
const actor = classicNexusSkillsPaymentsPlayer(paid, 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.reserves, before.reserves + 3); assert.equal(actor.tanks, before.tanks - 3);
assert.deepEqual(wallets(paid), wallets(f.game));
rejected(paid, f.actor, { type: 'revive', amount: 1, elite: 1 });
sameSkillCustody(f.game, paid); classicNexusSkillsPaymentsInventory(paid);
});
}
void test('actual undealt initialized setup retains original actor IDs and first offers without reinitializing or redealing', () => {
const setup = initializeClassicNexusSkillsPaymentsSetup({ advanced: true, kind: 'emperor-revival',
seatIds: ['authenticated-owner', 'authenticated-second', 'authenticated-third'] });
const before = structuredClone(setup);
const accepted = initializeClassicNexusSkillsPaymentsSetup({ initial: setup, kind: 'emperor-revival' });
assert.deepEqual(accepted, before);
const next = advanceClassicNexusSkillsPaymentsStep(accepted);
assert.equal(next.leaderSkills!.assignments.length, 1);
assert.equal(Object.keys(next.leaderSkills!.offers).length, Object.keys(setup.leaderSkills!.offers).length - 1);
assert.deepEqual(next.players.map(player => player.hand), before.players.map(player => player.hand),
'The actual already-dealt original starting hands are not replaced by skill selection');
let selecting = accepted;
while (selecting.setupStage === 'leaderSkills') {
assert.ok(selecting.players.every(player => !player.traitors.length && !player.traitorChoices.length),
'Every original skill choice precedes the original physical traitor deal');
classicNexusSkillsPaymentsInventory(selecting);
selecting = advanceClassicNexusSkillsPaymentsStep(selecting);
}
assert.equal(selecting.leaderSkills!.assignments.length, selecting.players.length);
assert.equal(selecting.setupStage, 'traitors');
const f = createClassicNexusSkillsPaymentsFixture({ initial: setup, kind: 'emperor-revival' });
assert.deepEqual(setup, before, 'The supplied actual CLI/setup snapshot is an immutable source');
assert.deepEqual(f.initial, before);
assert.equal(f.actor, 'authenticated-owner');
assert.deepEqual(f.game.players.map(player => player.id), before.players.map(player => player.id));
assert.equal(f.afterSetup.leaderSkills!.assignments.length, 3);
assert.equal(f.afterClosingDraw.nexusCards!.cards!.hands[f.actor], 'emperor');
const paid = payClassicNexusSkillsPaymentsFixture(f);
sameSkillCustody(f.game, paid); classicNexusSkillsPaymentsInventory(paid);
});
@@ -0,0 +1,402 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, handLimit, initializeLeaderSkillsGameForAudit,
joinGame, newPlayer, viewGame, type Action, type Game, type Player,
} from '../game/engine';
import { territory } from '../game/board';
import type { Card } from '../game/cards';
import type { FactionId } from '../game/catalog';
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
import { quoteBattleResolution, type ResolutionCombatant } from '../game/battle-resolution-quote';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
export type ClassicNexusSkillsBattleStep = { actor: string; action: Action };
export type ClassicNexusSkillsBattleOptions = {
/** Original undealt authenticated CLI setup, or fresh classic lobby. Never redealt. */
initial?: Game;
advanced?: boolean;
seatIds?: string[];
skill?: LeaderSkillId;
band?: 'normal' | 'skilled';
ownerDial?: number;
support?: number;
opponentDial?: number;
opponentSupport?: number;
ownerWeapon?: Card['kind'];
ownerDefense?: Card['kind'];
opponentWeapon?: Card['kind'];
opponentDefense?: Card['kind'];
traitors?: 'owner' | 'opponent' | 'both';
counter?: boolean;
secondBattle?: boolean;
};
export type ClassicNexusSkillsBattleFixture = {
initial: Game;
afterSetup: Game;
beforeFirstMentat: Game;
firstMentatStep: ClassicNexusSkillsBattleStep;
afterFirstMentat: Game;
beforeAlliance: Game;
allianceActions: ClassicNexusSkillsBattleStep[];
beforeDraw: Game;
drawAction: ClassicNexusSkillsBattleStep;
afterDraw: Game;
beforeBattle: Game;
battleAction: ClassicNexusSkillsBattleStep;
game: Game;
owner: string;
opponent: string;
observer: string;
partner: string;
trainer: string;
skill: LeaderSkillId;
band: 'normal' | 'skilled';
key: string;
secondKey?: string;
counter?: string;
planActions: ClassicNexusSkillsBattleStep[];
actions: ClassicNexusSkillsBattleStep[];
staging: string[];
};
export function classicNexusSkillsBattlePlayer(game: Game, actor: string): Player {
const player = game.players.find(seat => seat.id === actor);
assert.ok(player, `Missing original classic seat ${actor}`);
return player;
}
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
/** Scoped original shuffle/action entropy; generic non-word requests retain native forwarding. */
function entropy<T>(operation: () => T, rolls: number[] = []): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const native = crypto.getRandomValues.bind(crypto);
let cursor = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array) {
for (let index = 0; index < array.length; index++) array[index] = rolls[cursor++] ?? 0xffffffff;
} else Reflect.apply(native, crypto, [array]);
return array;
};
try { return operation(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
function offerRolls(selected: LeaderSkillId[]): number[] {
const working = LEADER_SKILL_CARDS.map(card => card.id);
const remainder = working.filter(skill => !selected.includes(skill));
const desired = selected.flatMap(skill => [skill, remainder.shift()!]);
desired.push(...remainder);
const rolls: number[] = [];
for (let index = working.length - 1; index > 0; index--) {
const from = working.indexOf(desired[index]);
assert.ok(from >= 0 && from <= index);
rolls.push(Math.floor((from + 0.5) * 0x100000000 / (index + 1)));
[working[index], working[from]] = [working[from], working[index]];
}
return rolls;
}
export function nextClassicNexusSkillsBattleStep(game: Game): ClassicNexusSkillsBattleStep | null {
if (clean(game) && game.nexusCards?.phase?.stage === 'drawing') {
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 } };
}
if (!game.phaseOpening && !game.response) {
const decision = game.decision;
if (decision?.kind === 'leaderSkillVisibility')
return { actor: decision.player, action: { type: 'leaderSkillVisibility', event: decision.event, hide: false } };
if (decision?.kind === 'mentatQuestion')
return { actor: decision.player, action: { type: 'decision', event: decision.event, decline: true } };
if (decision?.kind === 'wormRide')
return { actor: decision.player, action: { type: 'decision', accept: false } };
if (decision?.kind === 'nexusFremenCunningOffer' || decision?.kind === 'nexusFremenCunningRide')
return { actor: decision.player, action: { type: 'decision', event: decision.event, accept: false } };
if (decision?.kind === 'sukRescue') {
const choice = decision.options.map((option, index) => ({ index, saved: option.normal + option.elite, kept: !!option.kept }))
.sort((a, b) => b.saved - a.saved || Number(b.kept) - Number(a.kept))[0].index;
return { actor: decision.player, action: { type: 'decision', event: decision.event, choice } };
}
}
return nextStrongholdFactionsNativeStep(game);
}
export function stepClassicNexusSkillsBattle(game: Game, next = nextClassicNexusSkillsBattleStep(game)): Game {
assert.ok(next, 'Original private plan requires a human action');
return entropy(() => applyAction(game, next.actor, next.action));
}
export function advanceClassicNexusSkillsBattle(game: Game, until: (state: Game) => boolean,
actions: ClassicNexusSkillsBattleStep[] = []): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const next = nextClassicNexusSkillsBattleStep(game); assert.ok(next, 'Human plan boundary before requested state');
actions.push(structuredClone(next));
game = stepClassicNexusSkillsBattle(game, next);
}
throw Error('Original Skills/Nexus continuation did not reach its boundary');
}
/** Reorder undealt singleton Spice Cards only. Discards keep their original history. */
function orderSpice(game: Game, kinds: ('land' | 'worm')[], staging: string[]): void {
const remainder = [...game.spiceDeck];
const front = kinds.map(kind => {
const index = remainder.findIndex(card => kind === 'land' ? 'territory' in card : 'worm' in card);
assert.ok(index >= 0, `Original undealt ${kind} must still exist`);
return remainder.splice(index, 1)[0];
});
game.spiceDeck = [...front, ...remainder];
staging.push(`Controlled conserved original undealt Spice order: ${kinds.join(', ')}; not natural shuffle history`);
}
function reserveBoard(game: Game): void {
for (const seat of game.players) {
seat.reserves += Object.values(seat.forces).reduce((sum, count) => sum + count, 0);
seat.forces = {};
if (seat.elites) {
seat.elites.reserves += Object.values(seat.elites.forces).reduce((sum, count) => sum + count, 0);
seat.elites.forces = {};
}
if (seat.advisors) seat.advisors = {};
}
}
function place(game: Game, actor: string, key: string, count: number): void {
const seat = classicNexusSkillsBattlePlayer(game, actor);
assert.ok(seat.reserves - (seat.elites?.reserves ?? 0) >= count);
seat.reserves -= count;
seat.forces[key] = (seat.forces[key] ?? 0) + count;
}
function hold(game: Game, actor: string, matches: (card: Card) => boolean, staging: string[], used: string[]): string {
const seat = classicNexusSkillsBattlePlayer(game, actor);
let card = seat.hand.find(card => matches(card) && !used.includes(card.id));
if (!card) {
const from = game.deck.findIndex(card => matches(card) && !used.includes(card.id));
const donor = from < 0 ? game.players.find(seat => seat.hand.some(card => matches(card) && !used.includes(card.id))) : undefined;
assert.ok(from >= 0 || donor, 'Required original card must remain in deck/hand custody');
assert.ok(seat.hand.length < handLimit(seat));
card = from >= 0 ? game.deck.splice(from, 1)[0]
: donor!.hand.splice(donor!.hand.findIndex(card => matches(card) && !used.includes(card.id)), 1)[0];
seat.hand.push(card);
staging.push(`Conserved physical ${card.id}: ${donor?.id ?? 'original deck'} → ${actor} hand`);
}
used.push(card.id);
return card.id;
}
function traitor(game: Game, actor: string, identity: string, staging: string[]): void {
const seat = classicNexusSkillsBattlePlayer(game, actor);
if (seat.traitors.includes(identity)) return;
const returned = seat.traitors[0];
const from = game.traitorReserve?.indexOf(identity) ?? -1;
const donor = game.players.find(seat => seat.traitors.includes(identity));
assert.ok(returned && (from >= 0 || donor));
if (from >= 0) game.traitorReserve!.splice(from, 1, returned);
else donor!.traitors[donor!.traitors.indexOf(identity)] = returned;
seat.traitors[0] = identity;
staging.push(`Conserved original traitor ${identity}: ${donor?.id ?? 'reserve'} exchanged for ${actor}'s ${returned}`);
}
/** Genuine original all14/base33/all12 setup → Storm → END Mentat → worm →
* reciprocal alliance → closing Emperor draw → movement → human battle plans.
* Only labeled conserved physical sources are relocated; no wallets, phases,
* turn, skill effects, module owners, earned receipts or outcomes are assigned. */
export function createClassicNexusSkillsBattleFixture(options: ClassicNexusSkillsBattleOptions = {}): ClassicNexusSkillsBattleFixture {
const advanced = options.advanced ?? options.initial?.advanced ?? true;
const band = options.band ?? 'skilled';
const actions: ClassicNexusSkillsBattleStep[] = [], staging: string[] = [];
let game: Game;
if (options.initial) game = structuredClone(options.initial);
else {
const ids = options.seatIds ?? ['emperor', 'guild', 'atreides', 'fremen'];
assert.ok(ids.length >= 3 && ids.length <= 6 && new Set(ids).size === ids.length);
const factions: FactionId[] = ['emperor', 'guild', 'atreides', 'fremen', 'harkonnen', 'beneGesserit'];
game = createGame('CLASSICNEXUSSKILLSBATTLE', newPlayer(ids[0], factions[0], factions[0]), advanced, []);
for (let index = 1; index < ids.length; index++) joinGame(game, newPlayer(ids[index], factions[index], factions[index]));
}
assert.equal(game.advanced, advanced);
assert.deepEqual(game.expansions, []);
assert.ok(!game.techTokens && !game.strongholdCards && !game.homeworlds);
assert.ok(game.players.length >= 3 && game.players.length <= 6);
const owner = game.players.find(seat => seat.faction === 'emperor')?.id;
const opponent = game.players.find(seat => seat.faction === 'guild')?.id;
const observer = game.players.find(seat => seat.faction === 'atreides')?.id;
assert.ok(owner && opponent && observer);
const partner = game.players.find(seat => ![owner, opponent, observer].includes(seat.id))?.id ?? opponent;
const desiredSkill = options.skill ?? 'warmaster';
const passive: LeaderSkillId[] = ['sandmaster', 'smuggler', 'rihani-decipherer', 'prana-bindu-adept', 'killer-medic', 'master-of-assassins']
.filter(skill => skill !== desiredSkill) as LeaderSkillId[];
const selected = game.players.map(seat => seat.id === owner ? desiredSkill : passive.shift()!);
if (game.status === 'lobby') {
assert.ok(!game.leaderSkills, 'Never replace existing original skill custody');
game.nexusCards ??= { cards: null, phase: null };
assert.deepEqual(game.nexusCards, { cards: null, phase: null });
for (const seat of game.players) if (!seat.ready) game = stepClassicNexusSkillsBattle(game, { actor: seat.id, action: { type: 'ready' } });
game = entropy(() => initializeLeaderSkillsGameForAudit(game), offerRolls(selected));
}
assert.equal(game.status, 'setup', 'Only original fresh undealt setup/lobby is accepted');
assert.equal(game.turn, 1); assert.equal(game.phase, 0);
assert.ok(game.leaderSkills && game.nexusCards?.cards);
assert.equal(game.leaderSkills.assignments.length, 0);
const initial = structuredClone(game);
for (let count = 0; game.status === 'setup' && count < 240; count++) {
if (game.setupStage === 'leaderSkills') {
const actor: string = Object.keys(game.leaderSkills!.offers)[0];
const offer: NonNullable<Game['leaderSkills']>['offers'][string] = game.leaderSkills!.offers[actor];
const view = viewGame(game, actor).leaderSkills!;
const requested: LeaderSkillId = selected[game.players.findIndex(seat => seat.id === actor)];
const legal: LeaderSkillId[] = offer.cards.filter(skill => !view.unavailableSkills?.[skill]);
const skill: LeaderSkillId | undefined = legal.includes(requested) ? requested : legal[0];
assert.ok(skill, 'Existing saved offer has no legal original skill; it cannot be redealt');
if (actor === owner && options.skill) assert.equal(skill, options.skill, 'Requested skill must be in the original saved offer');
const eligible = new Set(view.eligibleLeaders.map(leader => leader.id));
const leader = classicNexusSkillsBattlePlayer(game, actor).leaders.filter(leader => eligible.has(leader.id))
.sort((a, b) => b.strength - a.strength)[0];
assert.ok(leader);
const next: ClassicNexusSkillsBattleStep = { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: leader.id } };
actions.push(next); game = stepClassicNexusSkillsBattle(game, next);
} else {
const next = nextClassicNexusSkillsBattleStep(game); assert.ok(next);
actions.push(next); game = stepClassicNexusSkillsBattle(game, next);
}
}
assert.equal(game.status, 'playing');
const afterSetup = structuredClone(game);
const assignment = game.leaderSkills!.assignments.find(skill => skill.owner === owner)!;
const skill = assignment.skill, trainer = assignment.leader;
game = advanceClassicNexusSkillsBattle(game, state => state.phase === 1 && clean(state), actions);
orderSpice(game, ['land', 'land'], staging);
game = advanceClassicNexusSkillsBattle(game, state => state.phase === 5 && clean(state), actions);
reserveBoard(game);
staging.push('After actual setup, conserve all board counters through their own reserves before first movement completion');
game = advanceClassicNexusSkillsBattle(game, state => state.phase === 8 && clean(state), actions);
let beforeFirstMentat!: Game, firstMentatStep!: ClassicNexusSkillsBattleStep;
while (game.turn === 1) {
const next = nextClassicNexusSkillsBattleStep(game); assert.ok(next);
const before = structuredClone(game);
actions.push(next); game = stepClassicNexusSkillsBattle(game, next);
if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = next; }
}
const afterFirstMentat = structuredClone(game);
game = advanceClassicNexusSkillsBattle(game, state => state.phase === 1 && clean(state), actions);
orderSpice(game, ['worm', 'land', 'land'], staging);
game = advanceClassicNexusSkillsBattle(game, state => state.nexus === true && !state.spiceWindow && clean(state), actions);
const beforeAlliance = structuredClone(game);
const allianceActions: ClassicNexusSkillsBattleStep[] = [
{ actor: observer, action: { type: 'alliance', target: partner } },
{ actor: partner, action: { type: 'alliance', target: observer } },
];
for (const next of allianceActions) { actions.push(next); game = stepClassicNexusSkillsBattle(game, next); }
game = advanceClassicNexusSkillsBattle(game, state => state.nexusCards?.phase?.stage === 'drawing', actions);
const cards = game.nexusCards!.cards!;
const from = cards.deck.indexOf('emperor'); assert.ok(from >= 0);
cards.deck.unshift(cards.deck.splice(from, 1)[0]);
staging.push('Conserved undealt Emperor Nexus singleton ordered first after genuine alliance; only actual closing draw changes hand custody');
const beforeDraw = structuredClone(game);
const drawAction: ClassicNexusSkillsBattleStep = { actor: owner, action: {
type: 'nexusCardChoice', turn: game.turn, card: cards.hands[owner], choice: 'draw', ownRedraws: 0,
} };
actions.push(drawAction); game = stepClassicNexusSkillsBattle(game, drawAction);
const afterDraw = structuredClone(game);
assert.equal(game.nexusCards!.cards!.hands[owner], 'emperor');
game = advanceClassicNexusSkillsBattle(game, state => state.phase === 5 && clean(state), actions);
reserveBoard(game);
const key = `arrakeen:${territory('arrakeen').sectors[0]}`;
assert.ok(!territory('arrakeen').sectors.includes(game.storm));
for (const actor of [owner, opponent]) place(game, actor, key, 7);
const secondKey = options.secondBattle ? `carthag:${territory('carthag').sectors[0]}` : undefined;
if (secondKey) {
assert.ok(!territory('carthag').sectors.includes(game.storm));
for (const actor of [owner, opponent]) place(game, actor, secondKey, 1);
}
staging.push('Before actual turn-two movement completion, conserved seven ordinary counters per combatant at Arrakeen; native elite pools unchanged');
if (secondKey) staging.push('Conserved one further ordinary counter per combatant at Carthag for genuine same-turn expiry battle');
const used: string[] = [];
const card = (actor: string, kind: Card['kind'] | undefined) => kind ? hold(game, actor, card => card.kind === kind, staging, used) : null;
const ownerWeapon = card(owner, options.ownerWeapon ?? (skill === 'warmaster' ? 'worthless' : undefined));
const ownerDefense = card(owner, options.ownerDefense);
const opponentWeapon = card(opponent, options.opponentWeapon);
const opponentDefense = card(opponent, options.opponentDefense);
const counter = options.counter ? hold(game, opponent, card => card.effect === 'karama', staging, used) : undefined;
const untrained = (actor: string) => {
const leaders = classicNexusSkillsBattlePlayer(game, actor).leaders.filter(leader => !leader.dead && !leader.usedAt &&
!game.leaderSkills!.assignments.some(assignment => assignment.leader === leader.id));
const leader = leaders.find(leader => leader.strength === 3) ?? leaders[0]; assert.ok(leader);
return leader.id;
};
const ownerLeader = band === 'skilled' ? trainer : untrained(owner);
const opponentLeader = untrained(opponent);
if (options.traitors === 'owner' || options.traitors === 'both') traitor(game, owner, opponentLeader, staging);
if (options.traitors === 'opponent' || options.traitors === 'both') traitor(game, opponent, ownerLeader, staging);
game = advanceClassicNexusSkillsBattle(game, state => state.phase === 6 && clean(state), actions);
const beforeBattle = structuredClone(game);
const actor = game.active!; assert.ok([owner, opponent].includes(actor));
const battleAction: ClassicNexusSkillsBattleStep = { actor, action: { type: 'chooseBattle', territory: 'arrakeen', target: actor === owner ? opponent : owner } };
actions.push(battleAction); game = stepClassicNexusSkillsBattle(game, battleAction);
const planActions: ClassicNexusSkillsBattleStep[] = [
{ actor: owner, action: { type: 'battlePlan', leader: ownerLeader, dial: options.ownerDial ?? 6,
support: advanced ? options.support ?? 3 : 0, weapon: ownerWeapon, defense: ownerDefense } },
{ actor: opponent, action: { type: 'battlePlan', leader: opponentLeader, dial: options.opponentDial ?? 0,
support: advanced ? options.opponentSupport ?? 0 : 0, weapon: opponentWeapon, defense: opponentDefense } },
];
return { initial, afterSetup, beforeFirstMentat, firstMentatStep, afterFirstMentat, beforeAlliance, allianceActions,
beforeDraw, drawAction, afterDraw, beforeBattle, battleAction, game, owner, opponent, observer, partner,
trainer, skill, band, key, secondKey, counter, planActions, actions, staging };
}
export function prepareClassicNexusSkillsBattle(fixture: ClassicNexusSkillsBattleFixture, state = fixture.game): Game {
let game = structuredClone(state);
for (let count = 0; count < 160; count++) {
const next = nextClassicNexusSkillsBattleStep(game);
if (!next) { assert.ok(game.battle && !game.battle.revealed); return game; }
if (next.action.type === 'leaderSkillVisibility') next.action.hide = next.actor === fixture.owner && fixture.band === 'skilled';
game = stepClassicNexusSkillsBattle(game, next);
}
throw Error('Original skill posture/private plan boundary did not open');
}
export function beginClassicNexusSkillsCunning(fixture: ClassicNexusSkillsBattleFixture, state = prepareClassicNexusSkillsBattle(fixture)): Game {
const offer = viewGame(state, fixture.owner).nexusSardaukar?.offer;
assert.ok(offer); assert.equal(offer.blocked, null);
return stepClassicNexusSkillsBattle(state, { actor: fixture.owner, action: { type: 'nexusSardaukar', event: offer.event } });
}
export function revealClassicNexusSkillsBattle(fixture: ClassicNexusSkillsBattleFixture,
options: { state?: Game; cunning?: boolean } = {}): Game {
let game = prepareClassicNexusSkillsBattle(fixture, options.state ?? fixture.game);
if (options.cunning) game = advanceClassicNexusSkillsBattle(beginClassicNexusSkillsCunning(fixture, game), clean);
for (const plan of fixture.planActions) {
game = stepClassicNexusSkillsBattle(game, plan);
while (game.response || game.phaseOpening || game.decision?.kind === 'fullPlanOffer') game = stepClassicNexusSkillsBattle(game);
}
assert.ok(game.battle?.revealed);
return game;
}
export function finishClassicNexusSkillsBattle(state: Game,
options: { stop?: 'losses' | 'rescue' | 'cleanup'; callers?: string[] } = {}): Game {
let game = structuredClone(state);
for (let count = 0; count < 240; count++) {
if (options.stop === 'losses' && game.decision?.kind === 'battleLosses') return game;
if (options.stop === 'rescue' && game.decision?.kind === 'sukRescue') return game;
if (options.stop === 'cleanup' && game.decision?.kind === 'battleCards') return game;
if (!game.battle && clean(game) && !game.pendingTreacheryDiscard) return game;
const next = nextClassicNexusSkillsBattleStep(game); assert.ok(next);
if (next.action.type === 'traitorCall') next.action.call = options.callers?.includes(next.actor) ?? false;
game = stepClassicNexusSkillsBattle(game, next);
}
throw Error('Original Skills/Nexus battle aftermath did not finish');
}
/** Quote the actual native source, including physical temporary roles and skill posture. */
export function quoteClassicNexusSkillsBattle(game: Game) {
const battle = game.battle; assert.ok(battle?.revealed);
const side = (actor: string): ResolutionCombatant => {
const seat = classicNexusSkillsBattlePlayer(game, actor), view = viewGame(game, actor).battle!;
assert.ok(view.ownForces);
return { id: actor, faction: seat.faction, ally: seat.ally, spice: seat.spice, hand: seat.hand,
plan: battle.plans[actor], leader: seat.leaders.find(leader => leader.id === battle.plans[actor].leader),
forces: view.ownForces, stronghold: view.strongholdEffects[actor],
leaderSkills: game.leaderSkills!.assignments.filter(assignment => assignment.owner === actor)
.map(assignment => ({ skill: assignment.skill, leader: assignment.leader,
faceUp: !battle.leaderSkillHidden?.[actor], captured: false })) };
};
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: [battle.attacker, battle.defender].includes(actor) ? actor : classicNexusSkillsBattlePlayer(game, actor).ally!,
called: battle.traitorCalls[actor] ?? false, traitors: classicNexusSkillsBattlePlayer(game, actor).traitors })),
participants: game.players, physicalCards: [...game.deck, ...game.discard, ...game.players.flatMap(seat => seat.hand)],
pendingAuditorPresent: false, pendingRetentionPresent: false });
}
@@ -0,0 +1,363 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game, type Player,
} from '../game/engine';
import type { FactionId } from '../game/catalog';
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
import { validateLeaderSkills } from '../game/leader-skills';
import { classicNexusLeaderSkillsProfile } from '../game/leader-skill-profile';
import { traitorDeck } from '../game/traitors';
import { nexusInventory } from './fixture-nexus-cards';
import { nextClassicSkillsStrongholdStep } from './fixture-classic-skills-stronghold';
export type ClassicNexusSkillsPaymentKind = 'emperor-revival' | 'fremen-revival' | 'emperor-purchase' | 'harkonnen-cunning';
export type ClassicNexusSkillsPaymentStep = { actor: string; action: Action };
export type ClassicNexusSkillsPaymentsOptions = {
/** Original authenticated lobby OR actual undealt leader-skills CLI setup. */
initial?: Game;
advanced?: boolean;
seatIds?: string[];
ownerFaction?: FactionId;
kind?: ClassicNexusSkillsPaymentKind;
skill?: LeaderSkillId;
tanks?: number;
eliteTanks?: number;
/** Reach a real revealed battle and commit the original call before Cunning. */
declaredTraitor?: 'call' | 'decline';
};
export type ClassicNexusSkillsPaymentsFixture = {
initial: Game;
afterSetup: Game;
beforeFirstMentat: Game;
firstMentatStep: ClassicNexusSkillsPaymentStep;
afterFirstMentat: Game;
beforeClosingDraw: Game;
closingDraw: ClassicNexusSkillsPaymentStep;
afterClosingDraw: Game;
beforePayment: Game;
game: Game;
actor: string;
opponent: string;
kind: ClassicNexusSkillsPaymentKind;
paymentAction: Action;
actions: ClassicNexusSkillsPaymentStep[];
planActions: ClassicNexusSkillsPaymentStep[];
staging: string[];
declaredIdentity?: string;
};
export const classicNexusSkillsPaymentsSnapshot = (game: Game): Game => structuredClone(game);
export function classicNexusSkillsPaymentsPlayer(game: Game, id: string): Player {
const player = game.players.find(p => p.id === id);
assert.ok(player, `Missing original seat ${id}`);
return player;
}
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
/** The first thirteen rolls control only the original all14 shuffle. Other
* scalar rolls are fixed; generic getRandomValues calls retain native behavior. */
function originalRandom<T>(run: () => T, selected?: LeaderSkillId[]): T {
const rolls: number[] = [];
if (selected) {
assert.equal(new Set(selected).size, selected.length);
const working: LeaderSkillId[] = LEADER_SKILL_CARDS.map(card => card.id);
const remainder = working.filter(card => !selected.includes(card));
const desired = selected.flatMap(card => [card, remainder.shift()!]);
desired.push(...remainder);
for (let i = working.length - 1; i > 0; i--) {
const j = working.indexOf(desired[i]);
assert.ok(j >= 0 && j <= i);
rolls.push(Math.floor((j + 0.5) * 0x100000000 / (i + 1)));
[working[i], working[j]] = [working[j], working[i]];
}
}
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const native = crypto.getRandomValues.bind(crypto);
let cursor = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1) array[0] = rolls[cursor++] ?? 0xffffffff;
else Reflect.apply(native, crypto, [array]);
return array;
};
try { return run(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
export function nextClassicNexusSkillsPaymentsNativeStep(game: Game): ClassicNexusSkillsPaymentStep | null {
if (game.status === 'setup' && game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0];
assert.ok(actor);
const view = viewGame(game, actor).leaderSkills!;
const skill = view.offer!.cards.find(card => !view.unavailableSkills?.[card]);
const eligible = new Set(view.eligibleLeaders.map(leader => leader.id));
const leader = classicNexusSkillsPaymentsPlayer(game, actor).leaders.filter(leader => eligible.has(leader.id))
.sort((a, b) => b.strength - a.strength)[0];
assert.ok(skill && leader, 'An original offer must have a legal skill/disc choice');
return { actor, action: { type: 'leaderSkill', event: view.offer!.event, skill, leader: leader.id } };
}
if (game.nexusCards?.phase?.stage === 'drawing' && clean(game)) {
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 nextClassicSkillsStrongholdStep(game);
}
export function advanceClassicNexusSkillsPaymentsStep(state: Game, actions?: ClassicNexusSkillsPaymentStep[]): Game {
const next = nextClassicNexusSkillsPaymentsNativeStep(state);
assert.ok(next, 'The original continuation needs a human battle plan');
actions?.push(structuredClone(next));
return applyAction(state, next.actor, next.action);
}
export function advanceClassicNexusSkillsPayments(state: Game, predicate: (game: Game) => boolean,
actions?: ClassicNexusSkillsPaymentStep[]): Game {
let game = state;
for (let n = 0; n < 1800; n++) {
if (predicate(game)) return game;
assert.notEqual(game.status, 'finished');
game = advanceClassicNexusSkillsPaymentsStep(game, actions);
}
throw Error('Original skill/Nexus continuation did not reach its boundary');
}
/** Checks the boundary BEFORE every action, including auto-skipped Battle. */
export function advanceClassicNexusSkillsPaymentsToPhase(state: Game, phase: number,
actions?: ClassicNexusSkillsPaymentStep[]): Game {
const turn = state.turn;
const game = advanceClassicNexusSkillsPayments(state, current => {
assert.equal(current.turn, turn);
return current.phase >= phase && clean(current);
}, actions);
assert.equal(game.turn, turn);
return game;
}
/** No replacement of a supplied setup's saved first offers or original IDs. */
export function initializeClassicNexusSkillsPaymentsSetup(options: ClassicNexusSkillsPaymentsOptions = {}): Game {
const kind = options.kind ?? 'emperor-revival';
const faction = options.ownerFaction ?? (kind === 'harkonnen-cunning' ? 'harkonnen' : 'atreides');
let game = options.initial ? structuredClone(options.initial)
: createGame('CLASSICNEXUSSKILLSPAYMENTS', newPlayer(options.seatIds?.[0] ?? 'payer', faction, faction), options.advanced ?? false);
if (!options.initial) {
const forbidden = kind === 'fremen-revival' ? 'fremen' : kind.startsWith('emperor-') ? 'emperor' : undefined;
const count = options.seatIds?.length ?? 3;
assert.ok(count >= 3 && count <= 6);
for (const candidate of ['harkonnen', 'guild', 'beneGesserit', 'atreides', 'fremen', 'emperor'] as const)
if (game.players.length < count && candidate !== forbidden && candidate !== faction)
joinGame(game, newPlayer(options.seatIds?.[game.players.length] ?? candidate, candidate, candidate));
assert.equal(game.players.length, count, 'The requested absent-faction power needs a compatible classic roster');
}
if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced);
assert.ok(game.players.some(p => p.faction === faction));
if (game.status === 'setup') {
assert.ok(classicNexusLeaderSkillsProfile(game));
assert.equal(game.setupStage, 'leaderSkills');
assert.equal(game.turn, 1); assert.equal(game.phase, 0);
assert.ok(game.players.every(p => !p.traitors.length && !p.traitorChoices.length));
assert.equal(game.leaderSkills!.assignments.length, 0);
classicNexusSkillsPaymentsInventory(game);
return game;
}
assert.equal(game.status, 'lobby');
if (!game.nexusCards) game.nexusCards = { cards: null, phase: null };
assert.ok(classicNexusLeaderSkillsProfile(game));
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
const skill = options.skill ?? 'sandmaster';
const passive: LeaderSkillId[] = ['sandmaster', 'smuggler', 'prana-bindu-adept', 'killer-medic', 'master-of-assassins', 'swordmaster-of-ginaz']
.filter(candidate => candidate !== skill) as LeaderSkillId[];
const selected = game.players.map(p => p.faction === faction ? skill : passive.shift()!);
return originalRandom(() => initializeLeaderSkillsGameForAudit(game), selected);
}
function reserveBoard(game: Game, staging: string[]): void {
for (const player of game.players) {
player.reserves += Object.values(player.forces).reduce((a, b) => a + b, 0);
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 = {};
}
staging.push('Conserved original on-board counters into their own reserves; no wallet, phase, turn or module owner assigned.');
}
function orderUndealtSpice(game: Game, kinds: ('land' | 'worm')[], staging: string[]): void {
const front = [];
for (const kind of kinds) {
const index = game.spiceDeck.findIndex(card => kind === 'land' ? 'territory' in card : 'worm' in card);
assert.ok(index >= 0, 'Only original still-undealt physical Spice Cards may be ordered');
front.push(game.spiceDeck.splice(index, 1)[0]);
}
game.spiceDeck.unshift(...front);
staging.push(`Ordered original undealt Spice Card singletons: ${kinds.join(', ')}; not a natural shuffle history.`);
}
function holdTraitor(game: Game, actor: string, identity: string, staging: string[]): void {
const owner = classicNexusSkillsPaymentsPlayer(game, actor);
if (owner.traitors.includes(identity)) return;
const returned = owner.traitors[0];
const index = game.traitorReserve!.indexOf(identity);
const donor = game.players.find(p => p.id !== actor && p.traitors.includes(identity));
assert.ok(returned && (index >= 0 || donor));
if (index >= 0) game.traitorReserve![index] = returned;
else donor!.traitors[donor!.traitors.indexOf(identity)] = returned;
owner.traitors[0] = identity;
staging.push(`Conserved original traitor ${identity}: exchanged ${returned} with ${donor?.id ?? 'reserve'} custody.`);
}
export function createClassicNexusSkillsPaymentsFixture(options: ClassicNexusSkillsPaymentsOptions = {}): ClassicNexusSkillsPaymentsFixture {
const initial = initializeClassicNexusSkillsPaymentsSetup(options);
return originalRandom(() => {
const kind = options.kind ?? 'emperor-revival';
const faction = options.ownerFaction ?? (kind === 'harkonnen-cunning' ? 'harkonnen' : 'atreides');
const actions: ClassicNexusSkillsPaymentStep[] = [], staging: string[] = [], planActions: ClassicNexusSkillsPaymentStep[] = [];
let game = advanceClassicNexusSkillsPayments(initial, state => state.status === 'playing', actions);
const afterSetup = structuredClone(game);
const actor = game.players.find(p => p.faction === faction)!.id;
assert.ok(game.players.length >= 3, 'A real alliance leaves this original seat unallied');
const opponent = game.players.find(p => p.id !== actor)!.id;
reserveBoard(game, staging);
game = advanceClassicNexusSkillsPaymentsToPhase(game, 1, actions);
orderUndealtSpice(game, ['land', 'land'], staging);
game = advanceClassicNexusSkillsPaymentsToPhase(game, 8, actions);
let beforeFirstMentat: Game | undefined, firstMentatStep: ClassicNexusSkillsPaymentStep | undefined;
while (game.turn === 1) {
const before = structuredClone(game);
const next = nextClassicNexusSkillsPaymentsNativeStep(game); assert.ok(next);
game = advanceClassicNexusSkillsPaymentsStep(game, actions);
if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = next; }
}
assert.ok(beforeFirstMentat && firstMentatStep);
const afterFirstMentat = structuredClone(game);
game = advanceClassicNexusSkillsPaymentsToPhase(game, 1, actions);
reserveBoard(game, staging);
orderUndealtSpice(game, ['worm', 'land', 'land'], staging);
let allied = false;
for (let n = 0; game.nexusCards?.phase?.stage !== 'drawing' && n < 240; n++) {
if (game.nexus && !game.spiceWindow && !game.spiceResolution && clean(game) && !allied) {
const others = game.players.filter(p => p.id !== actor);
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 = advanceClassicNexusSkillsPaymentsStep(game, actions);
}
assert.ok(allied); assert.equal(game.nexusCards?.phase?.stage, 'drawing');
const card: FactionId = kind === 'harkonnen-cunning' ? 'harkonnen' : kind === 'fremen-revival' ? 'fremen' : 'emperor';
const cards = game.nexusCards!.cards!;
const index = cards.deck.indexOf(card); assert.ok(index >= 0);
cards.deck.unshift(cards.deck.splice(index, 1)[0]);
staging.push(`Ordered original undealt ${card} Nexus singleton before the real closing draw; no hand grant.`);
const beforeClosingDraw = structuredClone(game);
const closingDraw: ClassicNexusSkillsPaymentStep = { actor, action: { type: 'nexusCardChoice', turn: game.turn,
card: cards.hands[actor], choice: 'draw', ownRedraws: 0 } };
actions.push(closingDraw); game = applyAction(game, actor, closingDraw.action);
assert.equal(game.nexusCards!.cards!.hands[actor], card);
const afterClosingDraw = structuredClone(game);
game = advanceClassicNexusSkillsPayments(game, state => state.nexusCards?.phase?.stage !== 'drawing', actions);
let paymentAction: Action, declaredIdentity: string | undefined;
if (kind === 'emperor-purchase') {
game = advanceClassicNexusSkillsPaymentsToPhase(game, 3, actions);
for (let n = 0; game.decision?.kind !== 'auctionPayment' && n < 160; 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 = advanceClassicNexusSkillsPaymentsStep(game, actions);
}
assert.equal(game.decision?.kind, 'auctionPayment'); assert.equal(game.decision!.player, actor);
assert.equal(game.auction!.bid, 2); assert.equal(game.auction!.bidder, actor);
paymentAction = { type: 'nexusEmperorPurchase', event: viewGame(game, actor).nexusEmperorSecretAlly!.event };
} else if (kind === 'harkonnen-cunning') {
assert.equal(faction, 'harkonnen');
if (options.declaredTraitor) {
game = advanceClassicNexusSkillsPaymentsToPhase(game, 5, actions);
reserveBoard(game, staging);
for (const id of [actor, opponent]) {
const player = classicNexusSkillsPaymentsPlayer(game, id);
assert.ok(player.reserves - (player.elites?.reserves ?? 0) >= 5);
player.reserves -= 5; player.forces['pasty_mesa:5'] = 5;
}
assert.notEqual(game.storm, 5, 'The actual retained storm must leave Pasty Mesa available');
staging.push('Conserved five ordinary reserve counters per combatant to Pasty Mesa sector five before genuine Movement completion.');
const untrained = (id: string) => classicNexusSkillsPaymentsPlayer(game, id).leaders
.filter(leader => !leader.dead && !game.leaderSkills!.assignments.some(a => a.leader === leader.id));
const ownLeader = untrained(actor).sort((a, b) => b.strength - a.strength)[0];
const targetLeader = untrained(opponent).sort((a, b) => a.strength - b.strength)[0];
assert.ok(ownLeader && targetLeader); declaredIdentity = targetLeader.id;
holdTraitor(game, actor, declaredIdentity, staging);
if (options.declaredTraitor === 'decline') {
const owner = classicNexusSkillsPaymentsPlayer(game, actor);
const replacement = game.traitorReserve![0];
owner.traitors[owner.traitors.indexOf(declaredIdentity)] = replacement;
game.traitorReserve![0] = declaredIdentity;
staging.push(`Conserved ${declaredIdentity}: exchanged back to the actual next traitor draw for ${replacement}.`);
}
game = advanceClassicNexusSkillsPaymentsToPhase(game, 6, actions);
assert.equal(game.phase, 6);
const battleActor = game.active!;
const battleAction: Action = { type: 'chooseBattle', territory: 'pasty_mesa', target: battleActor === actor ? opponent : actor };
actions.push({ actor: battleActor, action: battleAction }); game = applyAction(game, battleActor, battleAction);
game = advanceClassicNexusSkillsPayments(game, state => !nextClassicNexusSkillsPaymentsNativeStep(state), actions);
for (const [id, leader] of [[actor, ownLeader.id], [opponent, targetLeader.id]]) {
const plan: ClassicNexusSkillsPaymentStep = { actor: id, action: { type: 'battlePlan', dial: 0, support: 0, leader, weapon: null, defense: null } };
planActions.push(plan); actions.push(plan); game = applyAction(game, id, plan.action);
while (game.response || game.phaseOpening || game.decision?.kind === 'fullPlanOffer') game = advanceClassicNexusSkillsPaymentsStep(game, actions);
}
assert.ok(game.battle?.revealed);
const call: Action = { type: 'traitorCall', call: options.declaredTraitor === 'call' };
actions.push({ actor, action: call }); game = applyAction(game, actor, call);
} else game = advanceClassicNexusSkillsPaymentsToPhase(game, 8, actions);
const offer = viewGame(game, actor).nexusTraitors!.offer!;
assert.equal(offer.mode, 'cunning'); assert.equal(offer.blocked, null);
paymentAction = { type: 'nexusTraitorDraw', event: offer.event, mode: offer.mode };
} else {
game = advanceClassicNexusSkillsPaymentsToPhase(game, 4, actions);
const player = classicNexusSkillsPaymentsPlayer(game, actor);
const tanks = options.tanks ?? 6, elite = options.eliteTanks ?? 0;
assert.ok(Number.isInteger(tanks) && tanks >= 0 && elite >= 0 && elite <= tanks);
assert.ok(tanks - elite <= player.reserves - (player.elites?.reserves ?? 0) && 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} reserve counters to Tanks, including ${elite} original elites; no earned-revival receipt staged.`);
const view = viewGame(game, actor);
paymentAction = { type: kind === 'emperor-revival' ? 'nexusEmperorRevive' : 'nexusFremenRevive',
event: kind === 'emperor-revival' ? view.nexusEmperorSecretAlly!.event : view.nexusFremenRevival!.event,
elite: elite ? 1 : 0 };
}
classicNexusSkillsPaymentsInventory(game);
return { initial, afterSetup, beforeFirstMentat, firstMentatStep, afterFirstMentat, beforeClosingDraw,
closingDraw, afterClosingDraw, beforePayment: structuredClone(game), game, actor, opponent, kind,
paymentAction, actions, planActions, staging, declaredIdentity };
});
}
/** Stops at the real private Cunning return instead of silently choosing it. */
export function payClassicNexusSkillsPaymentsFixture(fixture: ClassicNexusSkillsPaymentsFixture, state = fixture.game): Game {
return applyAction(state, fixture.actor, fixture.paymentAction);
}
export function finishClassicNexusSkillsPaymentsFixture(fixture: ClassicNexusSkillsPaymentsFixture,
state: Game, returned?: string[]): Game {
let game = state;
if (fixture.kind === 'harkonnen-cunning') {
const pending = viewGame(game, fixture.actor).nexusTraitors!.pending!;
assert.ok(pending);
const cards = returned ?? pending.choices.filter(choice => choice.drawn).map(choice => choice.id);
game = applyAction(game, fixture.actor, { type: 'nexusTraitorReturn', event: pending.event, cards });
}
return advanceClassicNexusSkillsPayments(game, current => clean(current) && !current.pendingRevival && !current.nexusTraitorPending);
}
export function classicNexusSkillsPaymentsInventory(game: Game): void {
nexusInventory(game);
validateLeaderSkills(game.leaderSkills!, game.players);
assert.deepEqual([...game.leaderSkills!.deck, ...Object.values(game.leaderSkills!.offers).flatMap(offer => offer.cards),
...game.leaderSkills!.assignments.map(a => a.skill)].sort(), LEADER_SKILL_CARDS.map(card => card.id).sort());
if (game.status !== 'setup')
assert.deepEqual([...game.traitorReserve!, ...game.players.flatMap(player => player.traitors)].sort(),
traitorDeck(game.players).sort(), 'Every original traitor identity remains in reserve or actual hand custody');
for (const player of game.players) if (player.elites)
assert.equal(player.elites.reserves + player.elites.tanks + Object.values(player.elites.forces).reduce((a, b) => a + b, 0),
player.faction === 'emperor' ? 5 : 3, 'Original Advanced elite counters are conserved separately');
}
+14 -9
View File
@@ -32,7 +32,7 @@ import {
type Game,
} from '../game/engine';
import { sampleInventory, verifySampleCustody } from './sample-custody';
import { classicTechLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, strongholdLeaderSkillsProfile } from '../game/leader-skill-profile';
import { classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, strongholdLeaderSkillsProfile } from '../game/leader-skill-profile';
import { createTechTokens } from '../game/tech-tokens';
import { createStrongholdCards } from '../game/stronghold-cards';
import { privateOutputDirectory, sourceSnapshot } from './verification';
@@ -42,7 +42,7 @@ 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' | 'nexus-tech' | 'nexus-stronghold' | 'nexus-stronghold-tech'
| 'paired-nexus-tech' | 'paired-nexus-stronghold' | 'paired-nexus-stronghold-tech';
| 'paired-nexus-tech' | 'paired-nexus-stronghold' | 'paired-nexus-stronghold-tech' | 'nexus-skills';
type Rules = 'basic' | 'advanced';
type Scenario = {
@@ -117,6 +117,9 @@ const BASE_SCENARIOS: readonly Scenario[] = [2, 3, 4, 5, 6].flatMap((players) =>
roster: BASE_ROSTER.slice(0, players),
})),
);
const NEXUS_SKILLS_SCENARIOS: readonly Scenario[] = BASE_SCENARIOS.map((scenario, index) => ({
...scenario, profile: 'nexus-skills', ordinal: 1410 + index,
}));
const MODULE_SCENARIOS: readonly Scenario[] = [
...[2, 3, 4, 5, 6].flatMap((players) =>
(['basic', 'advanced'] as const).map((rules, index) => ({
@@ -412,7 +415,7 @@ const SKILLS_STRONGHOLD_SCENARIOS: readonly Scenario[] =
].filter(scenario => profile !== 'skills-stronghold-tech' || scenario.roster.length >= 3)
.map((scenario, index): Scenario => ({ ...scenario, ordinal: 376 + band * 100 + index, profile })));
const strongholdSkillProfile = (profile: string) => profile === 'skills-stronghold' || profile === 'skills-stronghold-tech';
const skillProfile = (profile: string) => profile === 'moritani-skills' || profile === 'tleilaxu-skills' || profile === 'ix-skills' || profile === 'choam-skills' || profile === 'richese-skills' || profile === 'ecaz-skills' || profile === 'skills-tech' || strongholdSkillProfile(profile);
const skillProfile = (profile: string) => profile === 'nexus-skills' || profile === 'moritani-skills' || profile === 'tleilaxu-skills' || profile === 'ix-skills' || profile === 'choam-skills' || profile === 'richese-skills' || profile === 'ecaz-skills' || profile === 'skills-tech' || strongholdSkillProfile(profile);
const assassinationScenario = (scenario: Scenario) => scenario.rules === 'advanced' &&
scenario.roster.includes('moritani') &&
(scenario.profile === 'moritani-assassinate' || nativeStrongholdProfile(scenario.profile) || nativeTechProfile(scenario.profile) || skillProfile(scenario.profile));
@@ -453,7 +456,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|nexus-tech|nexus-stronghold|nexus-stronghold-tech|paired-nexus-tech|paired-nexus-stronghold|paired-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] ' +
'[--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-skills|nexus-tech|nexus-stronghold|nexus-stronghold-tech|paired-nexus-tech|paired-nexus-stronghold|paired-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.' +
@@ -467,6 +470,7 @@ function usage() {
+ '\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.'
+ '\nPaired-nexus-tech adds original Tech to both Ixians+Tleilaxu OR CHOAM+Richese and classic opponents, one selected deck, three-through-six seats, Basic/Advanced. Paired-nexus-stronghold is Advanced2..6; paired-nexus-stronghold-tech is Advanced3..6. Native Cunning, real closing deals, typed forces/physical Face Dancers/No-Fields, original industry/rewards and held effects remain. No mixed families, E3, Skills, other overlays, new rulings, public starts or save conversion.'
+ '\nNexus-skills uses classic/base33/all14 Skills/all12 Nexus, Basic/Advanced two through six seats, with original setup/posture, actual closing draws and physical effects. Native factions, Tech/Strongholds/HW, Banker/Mentat previews, borrowed Smuggler shipment arithmetic and unresolved effects remain guarded; no public start or played-game conversion.'
);
}
@@ -533,7 +537,7 @@ function scenarioName(scenario: Scenario) {
function parseProfile(value: string | undefined) {
const profile = value ?? 'all';
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))
if (!skillProfile(profile) && !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';
}
@@ -583,7 +587,7 @@ function freshGame(scenario: Scenario) {
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' || 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')
if (scenario.profile === 'nexus-skills' || 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');
@@ -639,7 +643,7 @@ function resumedGame(path: string) {
throw new Error(
'--resume is not an incomplete faction-games snapshot with saved AI profiles.',
);
const composedSkills = (classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game) || strongholdLeaderSkillsProfile(game)) && !game.spiceBankerIncomePreview;
const composedSkills = (classicNexusLeaderSkillsProfile(game) || classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game) || strongholdLeaderSkillsProfile(game)) && !game.spiceBankerIncomePreview;
if (
(game.leaderSkills && !nativeExpansionLeaderSkillsProfile(game) && !composedSkills) ||
game.discoveryEnabled ||
@@ -650,10 +654,10 @@ function resumedGame(path: string) {
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, ...CLASSIC_NEXUS_MODULE_SCENARIOS, ...PAIRED_NEXUS_MODULE_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, ...PAIRED_NEXUS_MODULE_SCENARIOS, ...NEXUS_SKILLS_SCENARIOS].find(
(candidate) =>
(candidate.profile === 'homeworld' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.homeworlds &&
(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 &&
(candidate.profile === 'nexus-skills' || 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) || nexusTechProfile(candidate.profile)) === !!game.techTokens &&
(nativeStrongholdProfile(candidate.profile) || strongholdSkillProfile(candidate.profile) || nexusStrongholdProfile(candidate.profile)) === !!game.strongholdCards &&
@@ -880,6 +884,7 @@ async function main() {
const requestedSamples = profile === 'homeworld' || profile === 'nexus' || profile === 'homeworld-nexus'
? MODULE_SCENARIOS
: pairedNexusModuleProfile(profile) ? PAIRED_NEXUS_MODULE_SCENARIOS
: profile === 'nexus-skills' ? NEXUS_SKILLS_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
+6 -1
View File
@@ -51,6 +51,8 @@ export type PrototypeOptions = {
ecazTreachery?: boolean;
/** Private uniform-question preview; ordinary skill starts stay unchanged. */
mentatQuestion?: boolean;
/** Independent classic Nexus/Skills composition, not a public activation. */
nexusCards?: boolean;
};
export function isPrototypeProfile(value: string): value is PrototypeProfile {
return PROTOTYPE_PROFILES.some((profile) => profile === value);
@@ -94,9 +96,12 @@ export function startPrototypeRoom(
throw new Error('--ecaz-treachery requires the ecaz-occupy profile.');
if (options.mentatQuestion && profile !== 'leader-skills')
throw new Error('--mentat-question requires the leader-skills profile.');
if (options.nexusCards && profile !== 'leader-skills')
throw new Error('--nexus-cards requires the leader-skills profile.');
if (options.ecazTreachery) initial.ecazTreachery = true;
if (options.mentatQuestion) initial.mentatQuestionPreview = true;
if (profile === 'nexus' && !initial.nexusCards) initial.nexusCards = { cards: null, phase: null };
if ((profile === 'nexus' || options.nexusCards) && !initial.nexusCards)
initial.nexusCards = { cards: null, phase: null };
const game =
profile === 'ix'
? initializeIxGameForAudit(initial)
+6 -1
View File
@@ -17,6 +17,7 @@ async function main() {
out: { type: 'string' },
'ecaz-treachery': { type: 'boolean' },
'mentat-question': { type: 'boolean' },
'nexus-cards': { type: 'boolean' },
help: { type: 'boolean' },
},
});
@@ -32,6 +33,7 @@ async function main() {
'\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.',
'\nNexus additionally routes a supported paired native E1 (Ixians+Tleilaxu) OR E2 (CHOAM+Richese) lobby with classics and one required deck through original paired setup when Tech and/or Advanced Strongholds is selected. Tech requires3–6 Basic/Advanced, Strongholds2–6 Advanced, both3–6 Advanced. All12 Nexus, original47/35 decks/cache, native setup and actual module custody remain. Mixed families, E3, Skills, other overlays, public starts and pending rulings stay separate.',
'\nLeader-skills --nexus-cards explicitly adds all12 Nexus to a fresh classic/base33/all14 two-through-six-seat Basic/Advanced setup. Original skill/Traitor order and genuine closing Nexus deals remain. Native expansions, Tech, Strongholds, Homeworlds, Banker/Mentat previews, other overlays, public starts and unresolved interactions stay separate.',
);
return;
}
@@ -39,6 +41,8 @@ async function main() {
throw new Error('--ecaz-treachery requires --profile ecaz-occupy.');
if (values['mentat-question'] && values.profile !== 'leader-skills')
throw new Error('--mentat-question requires --profile leader-skills.');
if (values['nexus-cards'] && values.profile !== 'leader-skills')
throw new Error('--nexus-cards requires --profile leader-skills.');
if (
!isPrototypeProfile(values.profile) ||
!values.db ||
@@ -62,7 +66,7 @@ async function main() {
const db = new DatabaseSync(values.db);
try {
const result = startPrototypeRoom(db, values.room, version, values.profile,
{ ecazTreachery: values['ecaz-treachery'], mentatQuestion: values['mentat-question'] });
{ ecazTreachery: values['ecaz-treachery'], mentatQuestion: values['mentat-question'], nexusCards: values['nexus-cards'] });
writeFileSync(
resolve(values.out, 'prototype.json'),
JSON.stringify(
@@ -72,6 +76,7 @@ async function main() {
profile: values.profile,
ecazTreachery: !!values['ecaz-treachery'],
mentatQuestion: !!values['mentat-question'],
nexusCards: !!values['nexus-cards'],
source,
beforeVersion: version,
...result,