Compose paired Nexus and native skills with signed marker shipments

This commit is contained in:
Quintine committed 2026-10-04 22:30:26 +11:00
1 parent 54ce1de1df
commit fba673d215
14 files changed
+1621 -36

No files matched your search

+22
View File
@@ -16,6 +16,28 @@ open mode gates.
## Current checkpoint and work
**Paired E1/E2 Nexus / Leader Skills — bounded Development Verified:** both
Ixians+Tleilaxu OR CHOAM+Richese plus classics, one family deck/all14/all12,
Basic/Advanced2..6 through original `leader-skills --nexus-cards`, no overlays.
New27/affected136, types/lint/build, twenty real setup programs, fourteen native
programs/four legal pair policies and twenty genuine2..6-seat games pass:
7,458 actions/no rejection,195 JSON continuations/four actual closing choices.
Natural Cunning combinations are not claimed. Real Basic/Advanced pair proof
conflict now routes the native signed two-token frame through original
validation/response/Guild continuation, keeping single-marker skill proofs
and companion exclusions intact. Original CLI **FZ2END3W/GZFAT6AE v8/setup**
preserves first offers/hands/IDs after a scoped original14 shuffle.
Human390px **FZ2END3W v24/Collection** uses normal Suk with full Suboids:
three Cyborg losses save1, then only two actual Tank losses exchange for two
Suboids. One Suboid/two Cyborgs remain;15 reserves/2 ordinary Tanks,5 Cyborg
reserves/zero Cyborg Tanks. Support10→9 once precedes city2→11.
**GZFAT6AE v15/movement** pays Guild1 for immediate5/concealed3, then reveals
actual3:12 reserves/8 board/last-used3; original training remains.
Actual phone controls/inspectors/refresh pass without root390px overflow;
controlled positions are labeled and only QA rooms change.
[Scope and evidence](NEXUS_CARD_RULES.md#paired-e1e2-nexus-and-leader-skills-composition--4-october-2026)
preserve foreign replacement, companion/mixed-plan, native revival and all gates.
**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.
+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).
## Paired E1/E2 Nexus composition — 4 October 2026
Fresh original `leader-skills --nexus-cards` additionally composes both
Ixians+Tleilaxu OR CHOAM+Richese plus classics, one family47/35+cache deck,
all14 Skills/all12 Nexus, Basic/Advanced2..6, without other overlays.
Actual offers/hands/IDs, native setup and real closing deals remain.
Full unsupported Suboids compose original skill posture/bonuses and typed
Suk rescue before equal subsequent substitution. Only actual Cyborg Tank
losses can be recovered; support and losses do not repeat. Face Dance follows
winner rescue/cards/rewards, kills a matching trainer and returns its skill
once, with no second bounty. Tleilaxu Cunning preserves unrevealed stock and
ordinary Mentat allowance. Original Richese pair uses its signed two-token
frame, not an ordinary single-marker skill proof; one invoice/response/Guild
continuation and capped physical reveal remain. CHOAM Cunning's actual
Special fuel is discarded before a separate ordinary Planetologist move.
New27/affected136, types/lint/build, twenty actual setup programs, fourteen
native programs, four legal pair policies and twenty genuine2..6-seat games
(7,458 actions/no rejection,195 JSON continuations/four closing choices) pass.
Human390px **FZ2END3W v24/Collection** saves one of three lost Cyborgs,
then exchanges only two Suboids for two actual Cyborg Tank losses, leaving
one Suboid/two Cyborgs on board and zero Cyborg Tanks. Actual support10→9
precedes separate city2→11. **GZFAT6AE v15/movement** pays original one-marker
fee for immediate5/concealed3, then reveals real3, leaving12 reserves/8 ordinary
board/last-used3 and unchanged all14 training. Original controllers/inspectors/
refresh pass; only QA rooms change. [Canonical scope and evidence](NEXUS_CARD_RULES.md#paired-e1e2-nexus-and-leader-skills-composition--4-october-2026)
retains foreign replacement, companion/mixed-plan, native revival and other
pending guards, without certifying full modules or deployed acceptance.
## Classic Nexus composition — 4 October 2026
Fresh `leader-skills --nexus-cards` composes classic/base33/all14 Skills/all12
+77
View File
@@ -2,6 +2,83 @@
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.
## Paired E1/E2 Nexus and Leader Skills composition — 4 October 2026
The original fresh `leader-skills --nexus-cards` entry additionally admits
**both Ixians + Tleilaxu OR both CHOAM + Richese**, classic opponents,
one required family deck/all14 Skills/all12 Nexus, Basic/Advanced2..6.
Original47 or35+separate10 cache, native setup/offers, skill-before-Traitor
order and actual closing Nexus draws remain. Supplied starting hands/offers/
actor IDs are authoritative. No Tech, Strongholds, Homeworlds, mixed families,
E3, Banker/Mentat previews, independent cards, public start or save conversion.
This combines the original independent printed sources below and the
[skill source](LEADER_SKILLS_RULES.md), not a new timing or accounting ruling.
Ixian Cunning grants full unsupported Suboid strength through the actual turn,
while skill posture/discipline and Cyborg support remain original. Physical
Suk rescue finishes before equal Suboid substitution; only Cyborgs actually
sent to Tanks can be recovered. Neither operation repeats support/losses.
Actual skill rescue/card cleanup/winner rewards precede Face Dance. Its
matching winning trainer dies once and returns its original skill once,
without another bounty. Partial-stock Tleilaxu Cunning draws before retiring
revealed originals, preserves unrevealed stock and the ordinary Mentat allowance.
Supplied infeasible matching stock is not fabricated or replaced.
Richese's two-component Cunning retains one original tariff, capped immediate
ordinary group, concealed physical marker and real previous-token restriction.
Both Basic/Advanced reproduced a routing defect: ordinary single-marker skill
proof was inserted into the native signed pair frame. Pair declarations now
use their original signed two-token validator and native response/Guild
continuation checks. Single-marker skill proofs remain unchanged; no forged
proof, relaxed signature, extra frame key, companion or repeated invoice is added.
After voluntary reveal, ordinary native skill battles/rescue and original CHOAM
support income remain distinct. Own mixed No-Field dialing stays guarded.
CHOAM Cunning consumes one actual printed Special as Worthless fuel through
the original response/discard; Planetologist's subsequent physical move does
not replay that card or create a Special-card combat bonus.
```sh
node --import tsx tools/start-prototype.ts --profile leader-skills \
--nexus-cards --db /private/local.sqlite --room ROOMCODE --version 7 \
--out /private/new-paired-nexus-skills-entry
node --import tsx tools/faction-games.ts --profile paired-nexus-skills \
--players all --rules both --out /private/new-paired-nexus-skills-games
```
### Bounded paired skill development evidence
New27/affected136, types/lint/build, twenty actual paired2..6-seat setup
programs, fourteen native rule programs and all four legal pair policies pass.
Twenty genuine2..6-seat Basic/Advanced games complete7,458 accepted actions,
no rejection,195 JSON continuations and four actual closing Nexus choices.
Natural Cunning combinations are not claimed; controlled programs prove them.
`/tmp/dune-paired-nexus-skills-games-20261004/report.json` captures unchanged
tree `d763afdc75d52f5649cd0180b510d7d88a910568861efb15d8ee0c20e05b1458`.
Original backed-up CLI **FZ2END3W / GZFAT6AE v8/setup** uses only a scoped
original14 shuffle before native deals; original starting offers/hands and IDs
survive continuation. Controlled390px **FZ2END3W v24/Collection** keeps
C’tair's normal Suk face up, activates Suboid Cunning and seals Tessia5/
dial4/support1 versus Guild Bashar2/dial0. Real support1 debits10→9.
Three committed Cyborg losses save1 to reserves; only2 go to Tanks. Actual
two-Suboid/two-Cyborg exchange leaves one Suboid/two Cyborgs at Arrakeen,
15 reserves/2 ordinary Tanks, five Cyborg reserves/zero Cyborg Tanks.
Separate original city2 pays11. Suk/substitution receipts complete once.
**GZFAT6AE v15/movement** selects immediate5/concealed3 at Habbanya17,
passes the real native response and Guild interception, pays original Guild1
(Richese4→3/Guild6→7), then voluntarily reveals the remaining3 into real
ordinary counters:12 reserves/8 board, no deployed marker, unchanged last-used3.
Original all14 Suk/Planetologist assignments remain. Human posture/Cunning,
typed losses/equal substitution, physical token choices, response/allow/reveal,
inspectors and refreshed outcomes have no root390px overflow.
Only dedicated QA rooms change; controlled shuffle/order/relocation is labeled.
Borrowed Smuggler/pair companions, high-Richese extras, mixed plans, foreign
skill replacement, native free-revival accounting and pending Nexus reactions
remain guarded. Full rules, assurance, calibration, public and deployed gates
remain open.
## Classic Nexus and Leader Skills composition — 4 October 2026
The existing fresh local `leader-skills --nexus-cards` entry explicitly
+14
View File
@@ -85,6 +85,20 @@ entering mixed No-Field dialing. [Scope and bounded evidence](NEXUS_CARD_RULES.m
does not settle the separate cache, high-Richese, allied-marker, faceup-display
or public/full-module boundaries.
### Paired Leader Skills composition — 4 October 2026
The fresh original `leader-skills --nexus-cards` entry also composes paired
CHOAM/Richese/classics with the original35+cache/all14/all12, Basic/Advanced2..6,
without Tech/Strongholds/Homeworlds or other overlays. Ordinary skill-marker
proofs apply to single-marker declarations; the two-component Nexus operation
retains its separate original signed pair frame, native response/Guild checks,
one tariff and capped physical groups. A reproduced proof-routing conflict is
fixed without relaxing signatures or accepting a companion. Voluntary reveal
can precede ordinary normal/trained Suk battle/rescue and actual CHOAM support
income, not mixed No-Field dialing. [Scope and bounded proof](NEXUS_CARD_RULES.md#paired-e1e2-nexus-and-leader-skills-composition--4-october-2026)
does not settle high-Richese extras, faceup-display or other pending boundaries.
## Existing integration points and verification
+1
View File
@@ -208,6 +208,7 @@ See [the cutover and Advanced sequence](MORITANI_ASSASSINATE_LEADERS.md#authorit
| 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) |
| Paired E1/E2 Nexus / Leader Skills | Fresh both Ixians+Tleilaxu OR CHOAM+Richese plus classics, one original family deck/all14 Skills/all12 Nexus, Basic/Advanced2..6, explicit original skill entry. Actual native setup/offers/closing draws remain. Full unsupported Suboids compose typed Suk rescue before equal actual Tank substitution; Face Dance follows original winner outcomes, kills/returns the winning trainer skill once without another bounty, and partial Cunning retains ordinary Mentat rights. Richese pair uses its original signed two-token frame/one invoice/native response/Guild continuation, not single-marker skill proof; CHOAM printed fuel/Planetologist physical route remain separate. No other overlays, companion/mixed-plan/new ruling/public starts or retrofit. | [Scope, sources and bounded evidence](NEXUS_CARD_RULES.md#paired-e1e2-nexus-and-leader-skills-composition--4-october-2026), [Skill runtime](LEADER_SKILLS_RUNTIME.md#paired-e1e2-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 -14
View File
@@ -1,6 +1,6 @@
import { classicNexusModulesProfile, pairedNexusModulesProfile } from './nexus-module-profile';
import { nativeFactionTechProfile } from './faction-module-profile';
import { advancedMoritaniLeaderSkillsProfile, advancedNativeLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, strongholdLeaderSkillsProfile } from './leader-skill-profile';
import { advancedMoritaniLeaderSkillsProfile, advancedNativeLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, pairedNexusLeaderSkillsProfile, 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';
@@ -9400,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, classicNexusLeaderSkillsProfile(g), false, false, true,
const initialized = initializeSetupGameForAudit(g, false, classicNexusLeaderSkillsProfile(g) || pairedNexusLeaderSkillsProfile(g), false, false, true,
g.expansions.length === 1 && g.expansions[0] === 'choam', nativeExpansionLeaderSkillsProfile(g));
if (advancedMoritaniLeaderSkillsProfile(initialized)) initializeMoritaniAssassinateState(initialized);
return initialized;
@@ -10635,7 +10635,7 @@ function richeseBetrayalModeSupported(g: Game): boolean {
return g.expansions.length === 1 && g.expansions[0] === 'choam' &&
g.players.length >= 2 && g.players.length <= 6 &&
g.players.some(p => p.faction === 'richese') && g.players.some(p => p.faction === 'choam') &&
g.players.every(p => RICHESE_CUNNING_ROSTER.has(p.faction)) &&
g.players.every(p => RICHESE_CUNNING_ROSTER[p.faction] === true) &&
!!g.nexusCards && !g.homeworlds && !g.leaderSkills && !g.discoveryEnabled &&
!g.ecazTreachery && !g.sandtrout && !g.techTokens && !g.strongholdCards &&
!g.semutaPreview && !g.kullPreview;
@@ -23962,11 +23962,13 @@ function leaderSkillNoFieldIntegrity(g: Game): boolean {
for (const decision of guild) validateGuildShipmentDecision(g, decision);
return true;
}
if (!(shipment?.smugglerCompanion !== undefined || shipment?.noFieldSkillProof !== undefined ||
const pair = !!g.leaderSkills && !!shipment?.richesePair;
if (!(pair || shipment?.smugglerCompanion !== undefined || shipment?.noFieldSkillProof !== undefined ||
(g.leaderSkills && shipment?.noField) || responses.some(r => r.noFieldSkillProof !== undefined) ||
decisions.some(d => 'noFieldSkillProof' in d))) return false;
requireRule(shipment, 'The saved No-Field response lost its original shipment.');
validateLeaderSkillNoFieldShipment(g, shipment);
if (pair) currentRichesePairShipment(g, shipment);
else validateLeaderSkillNoFieldShipment(g, shipment);
const initial = responses.filter(r => r.kind === 'richeseNoField');
const guild = decisions.filter(d => d.kind === 'guildShipment');
requireRule(initial.length + guild.length === 1 &&
@@ -23982,8 +23984,9 @@ function leaderSkillNoFieldIntegrity(g: Game): boolean {
function validatePhysicalShipment(g: Game, shipment: PendingShipment) {
requireRule(!atomicsShipmentBlocked(g.moritaniAtomics, shipment.territory),
'Atomics Aftermath permanently blocks shipments into this territory.');
if (shipment.smugglerCompanion !== undefined || shipment.noFieldSkillProof !== undefined ||
(g.leaderSkills && shipment.noField)) validateLeaderSkillNoFieldShipment(g, shipment);
// The pair has its original signed two-token validator below; skill proofs bind single markers.
if (!shipment.richesePair && (shipment.smugglerCompanion !== undefined || shipment.noFieldSkillProof !== undefined ||
(g.leaderSkills && shipment.noField))) validateLeaderSkillNoFieldShipment(g, shipment);
if (shipment.smuggler) requireRule(
!shipment.source && !shipment.noField && !shipment.alliedNoField &&
!shipment.guildSecretEvent && !shipment.guildNexusEvent && !shipment.nexusEvent &&
@@ -24920,13 +24923,14 @@ function nexusRicheseOffer(g: Game, owner: string) {
else if (p.reserves < 1) blocked = 'You need physical forces in reserves.';
return {event:JSON.stringify(['nexusRichese',g.turn,owner]),blocked,maxForces:Math.min(5,p.reserves)};
}
const RICHESE_CUNNING_ROSTER = new Set<FactionId>([
'atreides', 'harkonnen', 'emperor', 'fremen', 'beneGesserit', 'guild', 'richese', 'choam',
]);
const RICHESE_CUNNING_ROSTER: Partial<Record<FactionId, true>> = {
atreides: true, harkonnen: true, emperor: true, fremen: true,
beneGesserit: true, guild: true, richese: true, choam: true,
};
function richeseCunningModeSupported(g: Game) {
return !g.homeworlds && !g.leaderSkills && !g.discoveryEnabled &&
return !g.homeworlds && (!g.leaderSkills || pairedNexusLeaderSkillsProfile(g)) && !g.discoveryEnabled &&
!g.ecazTreachery && !g.sandtrout &&
g.players.every(player => RICHESE_CUNNING_ROSTER.has(player.faction));
g.players.every(player => RICHESE_CUNNING_ROSTER[player.faction] === true);
}
function nexusRicheseCunningOffer(g: Game, owner: string) {
const p = g.players.find(player => player.id === owner);
@@ -29780,8 +29784,10 @@ function applyActionInner(
}),
...(noField ? { noField } : {}),
};
if (noField && g.leaderSkills) shipment.noFieldSkillProof = noFieldSkillProof(shipment);
if (noField && g.leaderSkills) validatePhysicalShipment(g, shipment);
if (noField && g.leaderSkills && !richesePair) {
shipment.noFieldSkillProof = noFieldSkillProof(shipment);
validatePhysicalShipment(g, shipment);
}
if (nexusEvent) recordNexusRicheseShipment(g,shipment);
bindGuildCunningShipment(g,p,'reserve',shipment);
if (shipment.noField) {
+39 -13
View File
@@ -33,19 +33,23 @@ export function noOtherLeaderSkillModules(game: LeaderSkillProfile): boolean {
!game.discoveryEnabled && !game.strongholdCards && !game.techTokens && !game.ecazTreachery;
}
function nexusLeaderSkillModulesSupported(game: LeaderSkillProfile): boolean {
return !!game.nexusCards && typeof game.advanced === 'boolean' &&
!!game.players && game.players.length >= 2 && game.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;
}
/** 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;
if (game.expansions.length || !nexusLeaderSkillModulesSupported(game)) return false;
const players = game.players!;
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;
@@ -56,9 +60,30 @@ export function classicNexusLeaderSkillsProfile(game: LeaderSkillProfile): boole
return true;
}
/** Both original natives in one E1/E2 family; independent decks and other overlays stay separate. */
export function pairedNexusLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
if (game.expansions.length !== 1 || !nexusLeaderSkillModulesSupported(game)) return false;
const expansion = game.expansions[0];
if (expansion !== 'ix' && expansion !== 'choam') return false;
const primary = expansion === 'ix' ? 'ixians' : 'choam';
const secondary = expansion === 'ix' ? 'tleilaxu' : 'richese';
const players = game.players!;
let hasPrimary = false, hasSecondary = 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 family: string | undefined;
for (const faction of FACTIONS)
if (faction.id === players[i].faction) { family = faction.expansion; break; }
if (family !== 'base' && family !== expansion) return false;
if (players[i].faction === primary) hasPrimary = true;
if (players[i].faction === secondary) hasSecondary = true;
}
return hasPrimary && hasSecondary;
}
/** E1/E2 native skills may add Tech and Advanced Strongholds; original roster/decks remain. */
function nativeLeaderSkillModulesSupported(game: LeaderSkillProfile): boolean {
if (noOtherLeaderSkillModules(game)) return true;
if (noOtherLeaderSkillModules(game) || pairedNexusLeaderSkillsProfile(game)) return true;
const players = game.players;
if ((!game.techTokens && !game.strongholdCards) || !players ||
players.length < (game.techTokens ? 3 : 2) || players.length > 6 ||
@@ -303,5 +328,6 @@ export function ordinaryLeaderSkillModeSupported(game: LeaderSkillProfile): bool
return (noOtherLeaderSkillModules(game) &&
(!game.expansions.length || nativeExpansionLeaderSkillsProfile(game))) ||
classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game) ||
strongholdLeaderSkillsProfile(game) || classicNexusLeaderSkillsProfile(game);
strongholdLeaderSkillsProfile(game) || classicNexusLeaderSkillsProfile(game) ||
pairedNexusLeaderSkillsProfile(game);
}
+3 -1
View File
@@ -4368,6 +4368,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'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.',
'The same fresh skill/Nexus entry also supports paired Ixians + Tleilaxu OR CHOAM + Richese with classics and one original family deck/all14/all12, Basic/Advanced two through six seats, without other overlays. Full Suboid Cunning composes typed skill rescue before equal actual Cyborg Tank substitution. Face Dance follows original skill/card/reward settlement, kills the matching trainer and returns its skill once without another bounty; partial Tleilaxu Cunning preserves ordinary Mentat rights. Original Richese pair and CHOAM printed fuel use their distinct physical operations. Mixed families, E3, companion/mixed-plan, native free-return accounting and other pending rulings stay separate.',
'Atreides Cunning adds a second, different opposing plan element after the first native answer. Both remain binding. Use it in your own battle before sealing your plan; Karama may cancel the extra attempt while preserving the first disclosure.',
'With Atreides absent, Secret Ally inspects one opposing element in your battle. A sealed plan supplies only that selected answer automatically. Otherwise the opponent chooses an answer that permits a legal plan. Other players receive no private answer.',
'The No-Field restriction names Atreides, so Cunning cannot inspect that dial. A non-Atreides Secret Ally holder may request it without learning the token denomination or other plan elements.',
@@ -4403,7 +4404,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Fremen Betrayal has two bounded advance-declaration alternatives for an unallied holder in a classic Nexus table. Before the first Spice Blow or worm appearance, spend the physical card to prevent Fremen ordinary and Cunning worm rides for the turn; worm survival, destruction and Nexus still resolve. This clean timing is a product boundary, not an official post-appearance priority ruling. Instead, before Fremen move during Shipment and Movement, spend it to suppress their native two-territory advantage for that turn while ordinary movement and independent ornithopters remain. Only one alternative can be used even if the card is recycled later that turn. Owner controls show the currently available choice; combined modules and reactive post-worm timing remain gated.',
'When Ecaz is absent, an unallied holder may spend its Nexus card at a quiet play boundary to ask whether any one player, including themself, holds one of the holder’s native faction leaders as an ordinary Traitor Card. The owner receives a private yes/no answer saved at the moment of use; no name or count is disclosed. Dead or captured native leaders count, but foreign Gholas, Duke Vidal, Cheap Hero and Face Dancers do not. Native-leader scope and private-only audience are explicit product interpretations, not publisher rulings; exceptional controlled-leader and public-disclosure combinations remain open.',
'When Richese is absent, its Secret Ally replaces your ordinary reserve shipment with up to five physical forces charged as one. Select the explicit shipment checkbox; forces, elite identity, destination restrictions, Homeworld reserve sources and normal movement remain unchanged. Fremen remain free within their normal reinforcement range; Guild and an active Karama rate retain their existing tariff and payment routing. This is not a concealed No-Field or an extra shipment.',
'Native Richese Cunning has a bounded two-No-Field shipment in genuine Richese/CHOAM Nexus games, including the supported fresh Tech/Advanced Stronghold composition without Skills. Choose distinct unused tokens: one reveals immediately into reserve-capped public forces, the other remains concealed; pay for one marker and spend the Nexus. The previous shipment token cannot be either selection; the concealed token becomes next last-used. Karama or Guild special prevention preserves physical tokens and unpaid spice, with the ordinary stopped-shipment allowance policy. An accepted off-planet pair accrues Heighliners once, collected only at phase end. Other module combinations and the full faceup-component display remain gated.',
'Native Richese Cunning has a bounded two-No-Field shipment in genuine Richese/CHOAM Nexus games, including supported fresh Tech/Advanced Strongholds without Skills or the separate paired all14 Skills composition without other overlays. Choose distinct unused tokens: one reveals immediately into reserve-capped public forces, the other remains concealed; pay for one marker and spend the Nexus. The previous shipment token cannot be either selection; the concealed token becomes next last-used. The skill composition retains the original signed pair frame and native response/Guild continuation, not a single-marker skill proof or free companion. Karama or Guild special prevention preserves physical tokens and unpaid spice, with the ordinary stopped-shipment allowance policy. When Tech is actually selected, an accepted off-planet pair accrues Heighliners once at phase end. Other module combinations, mixed dialing and full faceup-component display remain gated.',
'Richese Betrayal has a separate opt-in auction preview. Before payment, an unallied rival may spend its physical Richese Nexus card to discard a public self-cache win without any purchase debit, or redirect another buyer’s cache/Black Market payment to the bank while preserving delivery and earned bonus. Every publicly possible holder acknowledges the same opportunity; private Use eligibility cannot determine public timing. Normal hidden self-purchases and private special-Karama acquisitions are not connected. See the dedicated guide for its existing local runtime/browser evidence and remaining release gates; that evidence does not verify Nexus Kull.',
'Guild Cunning is declared when finishing your ordinary Shipment and Movement turn. It spends the Nexus and opens one Karama response. Allowance grants a second native shipment at normal Guild prices, including supported cross-shipment, return and Homeworld routes. Cancellation leaves the completed first turn intact. Only an unused Hajr move can follow the second shipment; no ordinary movement or third move is granted. The physical Ornithopter card combination remains unavailable. The separate guild-betrayal profile admits only original native shipment producers and explicitly blocks this second-shipment offer before Nexus spending; Cunning outside that profile is unchanged.',
'With Guild absent, its Secret Ally is an explicit optional source for one ordinary shipment at Guild prices. It supports physical reserve shipment, cross-shipment and return to abstract reserves, plus half-price independently legal world-to-world shipment. Fremen may retain free reinforcement or choose the paid card route beyond its normal range, outside the storm. Native faction, typed sources and normal movement stay unchanged. Homeworld reserve return awaits a ruling; arbitrary foreign-world transport and concealed No-Fields are not granted.',
@@ -4606,6 +4607,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'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.',
'The original paired Ixian/Tleilaxu or CHOAM/Richese native skill roster may also add Nexus alone through that explicit fresh local entry, with classics and one original family deck, Basic/Advanced two through six players. Full unsupported Suboids retain Cyborg support and skill posture. Suk physical rescue finishes before equal substitution of only actual Cyborg Tank losses; Face Dance then returns a killed trainer skill once without another bounty. Richese Cunning preserves its signed two-marker/one-invoice operation; ordinary skill marker proofs and companion exclusions remain separate. CHOAM Special fuel is discarded before an ordinary Planetologist move, without a second card effect or created combat Special.',
'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.',
+405
View File
@@ -0,0 +1,405 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, handLimit, initializeLeaderSkillsGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game, type GameView, type Player,
} from '../game/engine';
import { gameDistance, splitLocation, TERRITORIES, territory } from '../game/board';
import { treacheryDeck, type Card } from '../game/cards';
import type { FactionId } from '../game/catalog';
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
import { pairedNexusLeaderSkillsProfile } from '../game/leader-skill-profile';
import { validateLeaderSkills } from '../game/leader-skills';
import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards';
import { richeseCards } from '../game/richese-cards';
import { quoteBattleResolution, type ResolutionCombatant } from '../game/battle-resolution-quote';
import { nextNativeSkillsStrongholdStep } from './fixture-native-skills-stronghold';
export type PairedChoamNexusSkillsStep = { actor: string; action: Action };
export type PairedChoamNexusSkillsOptions = {
/** Original fresh lobby OR initialized turn-one setup; retain hands, offers and IDs. */
initial?: Game;
advanced?: boolean;
/** Fresh-lobby roster only. Setup supports every original two-to-six-seat pair. */
factions?: readonly FactionId[];
seatIds?: readonly string[];
/** Omit on a supplied setup to choose its actual available offer, never replace it. */
richeseSkill?: LeaderSkillId;
choamSkill?: LeaderSkillId;
reserveCap?: number;
};
export type PairedChoamNexusSkillsFixture = {
initial: Game;
afterSetup: Game;
afterFirstStorm: Game;
firstShipment: { before: Game; step: PairedChoamNexusSkillsStep; after: Game };
beforeFirstMentat: Game;
firstMentatStep: PairedChoamNexusSkillsStep;
afterFirstMentat: Game;
beforeAlliance: Game;
allianceActions: PairedChoamNexusSkillsStep[];
beforeClosingDraw: Game;
closingDraws: { before: Game; step: PairedChoamNexusSkillsStep; after: Game }[];
movementStart: Game;
game: Game;
richese: string;
choam: string;
guild: string;
observer: string;
location: string;
trainers: { richese: string; choam: string };
actions: PairedChoamNexusSkillsStep[];
staging: string[];
};
export const pairedSkillsSnapshot = (game: Game): Game => structuredClone(game);
export const pairedSkillsClean = (game: Game): boolean => !game.phaseOpening && !game.response && !game.decision;
export function pairedSkillsPlayer(game: Game, actor: string): Player {
const player = game.players.find(p => p.id === actor);
assert.ok(player, `Missing original paired E2 skill actor ${actor}`);
return player;
}
/** Control only the original all14 shuffle before native starting deals. Generic
* requests use the bound native method and still return the supplied array. */
function setupEntropy<T>(operation: () => T, selected: LeaderSkillId[]): T {
assert.equal(new Set(selected).size, selected.length);
const working: LeaderSkillId[] = LEADER_SKILL_CARDS.map(c => c.id);
const remainder = working.filter(c => !selected.includes(c));
const desired: LeaderSkillId[] = selected.flatMap(c => [c, remainder.shift()!]);
desired.push(...remainder);
const rolls: number[] = [];
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 operation(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
export function initializePairedChoamNexusSkills(options: PairedChoamNexusSkillsOptions = {}): Game {
const factions: readonly FactionId[] = options.factions ?? ['richese', 'choam', 'guild', 'atreides'];
let game = options.initial ? pairedSkillsSnapshot(options.initial)
: createGame('PAIREDCHOAMNEXUSSKILLS', newPlayer(options.seatIds?.[0] ?? factions[0], factions[0], factions[0]), options.advanced ?? true, ['choam']);
if (!options.initial) for (const faction of factions.slice(1))
joinGame(game, newPlayer(options.seatIds?.[game.players.length] ?? faction, faction, faction));
if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced);
assert.ok(game.status === 'lobby' || game.status === 'setup' && game.turn === 1 && game.phase === 0 && game.leaderSkills?.assignments.length === 0,
'Continue only the original lobby or initialized unassigned turn-one skill setup');
if (game.status === 'lobby' && !game.nexusCards) game.nexusCards = { cards: null, phase: null };
assert.ok(pairedNexusLeaderSkillsProfile(game));
assert.deepEqual(game.expansions, ['choam']);
if (game.status === 'setup') {
assert.equal(game.setupStage, 'leaderSkills');
assertPairedChoamSkillsCustody(game);
return game;
}
for (const p of game.players) if (!p.ready) game = applyAction(game, p.id, { type: 'ready' });
const richeseSkill = options.richeseSkill ?? 'suk-graduate';
const choamSkill = options.choamSkill ?? 'planetologist';
const passive: LeaderSkillId[] = ['sandmaster', 'warmaster', 'killer-medic', 'prana-bindu-adept', 'master-of-assassins', 'swordmaster-of-ginaz']
.filter(c => c !== richeseSkill && c !== choamSkill) as LeaderSkillId[];
const selected: LeaderSkillId[] = game.players.map(p => p.faction === 'richese' ? richeseSkill : p.faction === 'choam' ? choamSkill : passive.shift()!);
return setupEntropy(() => initializeLeaderSkillsGameForAudit(game), selected);
}
export function nextPairedChoamSkillsStep(game: Game): PairedChoamNexusSkillsStep | null {
if (game.status === 'setup' && game.setupStage === 'leaderSkills') {
const actor: string = Object.keys(game.leaderSkills!.offers)[0];
assert.ok(actor);
const offer: NonNullable<GameView['leaderSkills']> = viewGame(game, actor).leaderSkills!;
const skill: LeaderSkillId | undefined = offer.offer!.cards.find(c => !offer.unavailableSkills?.[c]);
const eligible = new Set(offer.eligibleLeaders.map(l => l.id));
const leader = pairedSkillsPlayer(game, actor).leaders.filter(l => eligible.has(l.id)).sort((a, b) => b.strength - a.strength)[0];
assert.ok(skill && leader);
return { actor, action: { type: 'leaderSkill', event: offer.offer!.event, skill, leader: leader.id } };
}
if (pairedSkillsClean(game) && game.nexusCards?.phase?.stage === 'drawing') {
const phase = game.nexusCards.phase;
const actor: string | undefined = 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 === '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 === 'nexusChoamInspection')
return { actor: decision.player, action: { type: 'decision', event: decision.event, inspect: false } };
}
return nextNativeSkillsStrongholdStep(game);
}
export function stepPairedChoamSkills(game: Game, next = nextPairedChoamSkillsStep(game), actions?: PairedChoamNexusSkillsStep[]): Game {
assert.ok(next, 'The original paired skill continuation needs a human plan');
actions?.push(structuredClone(next));
return applyAction(game, next.actor, next.action);
}
export function advancePairedChoamSkills(game: Game, until: (state: Game) => boolean, actions?: PairedChoamNexusSkillsStep[]): Game {
for (let n = 0; n < 1800; n++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
game = stepPairedChoamSkills(game, undefined, actions);
}
throw Error('Original paired E2 Skills continuation did not reach its requested boundary');
}
export function advancePairedChoamSkillsToPhase(game: Game, phase: number, actions?: PairedChoamNexusSkillsStep[]): Game {
const turn = game.turn;
return advancePairedChoamSkills(game, state => {
assert.equal(state.turn, turn, 'Do not chase an auto-skipped phase into the next turn');
return state.phase >= phase && pairedSkillsClean(state);
}, actions);
}
export function completePairedChoamSkillsSetup(game: Game, options: PairedChoamNexusSkillsOptions = {}, actions?: PairedChoamNexusSkillsStep[]): Game {
for (let n = 0; game.status === 'setup' && n < 200; n++) {
if (game.setupStage === 'leaderSkills') {
const actor: string = Object.keys(game.leaderSkills!.offers)[0];
const p = pairedSkillsPlayer(game, actor);
const requested: LeaderSkillId | undefined = p.faction === 'richese' ? options.richeseSkill : p.faction === 'choam' ? options.choamSkill : undefined;
const offer: NonNullable<GameView['leaderSkills']> = viewGame(game, actor).leaderSkills!;
const skill: LeaderSkillId | undefined = requested ?? offer.offer!.cards.find(c => !offer.unavailableSkills?.[c]);
assert.ok(skill && offer.offer!.cards.includes(skill) && !offer.unavailableSkills?.[skill], 'Requested skill must genuinely exist in the saved original offer');
const eligible = new Set(offer.eligibleLeaders.map(l => l.id));
const leader = p.leaders.filter(l => eligible.has(l.id)).sort((a, b) => b.strength - a.strength)[0];
assert.ok(leader);
game = stepPairedChoamSkills(game, { actor, action: { type: 'leaderSkill', event: offer.offer!.event, skill, leader: leader.id } }, actions);
} else game = stepPairedChoamSkills(game, undefined, actions);
}
assert.equal(game.status, 'playing');
return game;
}
export function pairedSkillsPhysicalCards(game: Game): string[] {
return [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? [])].map(c => c.id).sort();
}
export function assertPairedChoamSkillsCustody(game: Game): void {
validateLeaderSkills(game.leaderSkills!, game.players);
const skills = game.leaderSkills!;
assert.deepEqual([...skills.deck, ...Object.values(skills.offers).flatMap(o => o.cards), ...skills.assignments.map(a => a.skill)].sort(), LEADER_SKILL_CARDS.map(c => c.id).sort());
validateNexusCards(game.nexusCards!.cards!, game.players);
const cards = game.nexusCards!.cards!;
assert.deepEqual([...cards.deck, ...cards.discard, ...Object.values(cards.hands).filter((c): c is FactionId => c !== null)].sort(), [...NEXUS_FACTIONS].sort());
assert.deepEqual(pairedSkillsPhysicalCards(game), [...treacheryDeck(['choam']), ...richeseCards()].map(c => c.id).sort());
for (const p of game.players) {
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0), 20, p.faction);
if (p.elites) assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((a, b) => a + b, 0), p.faction === 'fremen' ? 3 : 5);
}
}
function reserveBoard(game: Game, staging: string[]): void {
for (const p of game.players) {
p.reserves += Object.values(p.forces).reduce((a, b) => a + b, 0); p.forces = {};
if (p.elites) {
p.elites.reserves += Object.values(p.elites.forces).reduce((a, b) => a + b, 0); p.elites.forces = {};
}
if (p.advisors) p.advisors = {};
}
staging.push('Conserved original board counters into their own reserves; wallets, turns, phases, skills and custody receipts are unchanged.');
}
export function placePairedSkillsForce(game: Game, actor: string, key: string, count: number, staging?: string[]): void {
const p = pairedSkillsPlayer(game, actor);
assert.ok(Number.isSafeInteger(count) && count >= 0 && p.reserves - (p.elites?.reserves ?? 0) >= count);
p.reserves -= count;
if (count) p.forces[key] = (p.forces[key] ?? 0) + count;
staging?.push(`Conserved ${count} ordinary ${p.faction} counters: native reserves → ${key}; not played movement history.`);
}
function orderUndealtSpice(game: Game, kinds: ('worm' | 'land')[], staging: string[]): void {
const front = kinds.map(kind => {
const index = game.spiceDeck.findIndex(c => kind === 'worm' ? 'worm' in c : 'territory' in c);
assert.ok(index >= 0, 'Only genuine still-undealt Spice Cards can be ordered');
return game.spiceDeck.splice(index, 1)[0];
});
game.spiceDeck.unshift(...front);
staging.push(`Conserved original undealt Spice order: ${kinds.join(', ')}; played Spice Discards remain untouched.`);
}
export function holdPairedSkillsCard(game: Game, actor: string, matches: (card: Card) => boolean, staging: string[] = []): Card {
const p = pairedSkillsPlayer(game, actor), held = p.hand.find(matches);
if (held) return held;
const index = game.deck.findIndex(matches);
assert.ok(index >= 0 && p.hand.length < handLimit(p), 'Required original physical card and native hand capacity must exist');
const card = game.deck.splice(index, 1)[0]; p.hand.push(card);
staging.push(`Conserved original ${card.id}: unused E2 deck → ${p.faction} hand; not a natural deal.`);
return card;
}
/** Native starts/first deals → actual Storm → previous zero-marker shipment →
* actual END Mentat → undealt worm → reciprocal classic alliance → two own
* closing Cunning draws. No skill effects, marker history or wallets are granted. */
export function createPairedChoamNexusSkillsFixture(options: PairedChoamNexusSkillsOptions = {}): PairedChoamNexusSkillsFixture {
const actions: PairedChoamNexusSkillsStep[] = [], staging: string[] = [];
let game = initializePairedChoamNexusSkills(options);
const initial = pairedSkillsSnapshot(game);
game = completePairedChoamSkillsSetup(game, options, actions);
const afterSetup = pairedSkillsSnapshot(game);
const richese = game.players.find(p => p.faction === 'richese')!.id;
const choam = game.players.find(p => p.faction === 'choam')!.id;
const guild = game.players.find(p => p.faction === 'guild')?.id;
const observer = game.players.find(p => p.faction !== 'richese' && p.faction !== 'choam' && p.faction !== 'guild')?.id;
assert.ok(guild && observer, 'Both own Cunning deals require two other real alliance seats including native Guild');
const location = `habbanya_ridge_sietch:${territory('habbanya_ridge_sietch').sectors[0]}`;
game = advancePairedChoamSkillsToPhase(game, 1, actions);
const afterFirstStorm = pairedSkillsSnapshot(game);
orderUndealtSpice(game, ['land', 'land'], staging);
game = advancePairedChoamSkills(game, g => g.phase === 5 && g.active === richese && pairedSkillsClean(g), actions);
reserveBoard(game, staging);
const before = pairedSkillsSnapshot(game), p = pairedSkillsPlayer(game, richese);
const shipmentStep: PairedChoamNexusSkillsStep = { actor: richese, action: { type: 'ship',
noField: p.noField!.tokens.find(t => t.value === 0)!.id, event: p.noFieldEvent,
territory: 'habbanya_ridge_sietch', sector: territory('habbanya_ridge_sietch').sectors[0] } };
game = stepPairedChoamSkills(game, shipmentStep, actions);
game = allowPairedChoamSkills(game, actions);
const firstShipment = { before, step: shipmentStep, after: pairedSkillsSnapshot(game) };
game = revealPairedSkillsRichese(game, richese, actions);
game = advancePairedChoamSkillsToPhase(game, 8, actions);
reserveBoard(game, staging);
placePairedSkillsForce(game, richese, location, 1, staging);
placePairedSkillsForce(game, choam, `arrakeen:${territory('arrakeen').sectors[0]}`, 1, staging);
let beforeFirstMentat: Game | undefined, firstMentatStep: PairedChoamNexusSkillsStep | undefined;
while (game.turn === 1) {
const previous: Game = pairedSkillsSnapshot(game);
const next: PairedChoamNexusSkillsStep | null = nextPairedChoamSkillsStep(game); assert.ok(next);
game = stepPairedChoamSkills(game, next, actions);
if (game.turn === 2) { beforeFirstMentat = previous; firstMentatStep = next; }
}
assert.ok(beforeFirstMentat && firstMentatStep);
const afterFirstMentat = pairedSkillsSnapshot(game);
game = advancePairedChoamSkillsToPhase(game, 1, actions);
orderUndealtSpice(game, ['worm', 'land', 'land'], staging);
game = advancePairedChoamSkills(game, g => g.nexus === true && !g.spiceWindow && !g.spiceResolution && pairedSkillsClean(g), actions);
const beforeAlliance = pairedSkillsSnapshot(game), allianceActions: PairedChoamNexusSkillsStep[] = [
{ actor: guild, action: { type: 'alliance', target: observer } },
{ actor: observer, action: { type: 'alliance', target: guild } },
];
for (const next of allianceActions) game = stepPairedChoamSkills(game, next, actions);
game = advancePairedChoamSkills(game, g => g.nexusCards?.phase?.stage === 'drawing' && pairedSkillsClean(g), actions);
const beforeClosingDraw = pairedSkillsSnapshot(game), closingDraws: PairedChoamNexusSkillsFixture['closingDraws'] = [];
for (const [actor, card] of [[richese, 'richese'], [choam, 'choam']] as const) {
const cards = game.nexusCards!.cards!, index = cards.deck.indexOf(card); assert.ok(index >= 0);
cards.deck.unshift(cards.deck.splice(index, 1)[0]);
staging.push(`Conserved original unused ${card} Nexus singleton placed first before its actual closing draw.`);
const drawBefore = pairedSkillsSnapshot(game);
const next: PairedChoamNexusSkillsStep = { actor, action: { type: 'nexusCardChoice', turn: game.turn, card: null, choice: 'draw', ownRedraws: 0 } };
game = stepPairedChoamSkills(game, next, actions);
closingDraws.push({ before: drawBefore, step: next, after: pairedSkillsSnapshot(game) });
}
game = advancePairedChoamSkillsToPhase(game, 5, actions);
reserveBoard(game, staging);
const movementStart = pairedSkillsSnapshot(game);
game = advancePairedChoamSkills(game, g => g.phase === 5 && g.active === richese && pairedSkillsClean(g), actions);
if (options.reserveCap !== undefined) {
const p = pairedSkillsPlayer(game, richese);
assert.ok(Number.isSafeInteger(options.reserveCap) && options.reserveCap >= 0 && options.reserveCap <= p.reserves);
placePairedSkillsForce(game, richese, 'polar_sink:0', p.reserves - options.reserveCap, staging);
}
assertPairedChoamSkillsCustody(game);
const trainers = { richese: game.leaderSkills!.assignments.find(a => a.owner === richese)!.leader,
choam: game.leaderSkills!.assignments.find(a => a.owner === choam)!.leader };
return { initial, afterSetup, afterFirstStorm, firstShipment, beforeFirstMentat, firstMentatStep, afterFirstMentat,
beforeAlliance, allianceActions, beforeClosingDraw, closingDraws, movementStart, game, richese, choam, guild, observer, location, trainers, actions, staging };
}
/** Exact failure-before API for the parent's real signed-pair validator integration.
* This is the original action shape, with no fabricated skill proof/companion. */
export function pairedSkillsRicheseCunningRequest(f: PairedChoamNexusSkillsFixture, game = f.game): Action {
const p = pairedSkillsPlayer(game, f.richese), offer = viewGame(game, f.richese).nexusRicheseCunning;
assert.ok(offer && !offer.blocked);
return { type: 'ship', noField: p.noField!.tokens.find(t => t.value === 3)!.id,
revealedToken: p.noField!.tokens.find(t => t.value === 5)!.id, nexus: offer.event, event: p.noFieldEvent,
territory: 'habbanya_ridge_sietch', sector: territory('habbanya_ridge_sietch').sectors[0], allyPayment: 0 };
}
export function allowPairedChoamSkills(game: Game, actions?: PairedChoamNexusSkillsStep[]): Game {
return advancePairedChoamSkills(game, pairedSkillsClean, actions);
}
export function revealPairedSkillsRichese(game: Game, actor: string, actions?: PairedChoamNexusSkillsStep[]): Game {
const p = pairedSkillsPlayer(game, actor); assert.ok(p.noField!.deployed);
return stepPairedChoamSkills(game, { actor, action: { type: 'revealNoField', event: p.noFieldEvent, token: p.noField!.deployed.tokenId } }, actions);
}
export function openPairedSkillsBattle(game: Game, owner: string, opponent: string,
options: { territory?: string; band?: 'normal' | 'skilled'; actions?: PairedChoamNexusSkillsStep[] } = {}): Game {
const actions = options.actions;
game = advancePairedChoamSkillsToPhase(game, 6, actions);
assert.equal(game.phase, 6, 'The genuine battle needs conserved opposing physical forces');
assert.ok(game.active === owner || game.active === opponent);
game = stepPairedChoamSkills(game, { actor: game.active!, action: { type: 'chooseBattle',
territory: options.territory ?? 'habbanya_ridge_sietch', target: game.active === owner ? opponent : owner } }, actions);
for (let n = 0; n < 100; n++) {
if (pairedSkillsClean(game) && game.battle && (!game.battle.preLeader || game.battle.preLeader.closed) && !game.battle.preparation) return game;
const decision = game.decision;
const next: PairedChoamNexusSkillsStep | null = decision?.kind === 'leaderSkillVisibility'
? { actor: decision.player, action: { type: 'leaderSkillVisibility', event: decision.event,
hide: decision.player === owner && options.band === 'skilled' } }
: nextPairedChoamSkillsStep(game);
game = stepPairedChoamSkills(game, next, actions);
}
throw Error('The original battle did not reach its actual human plan boundary');
}
export function pairedSkillsBattlePlans(game: Game, owner: string, opponent: string, dial = 2,
trained = true): PairedChoamNexusSkillsStep[] {
const p = pairedSkillsPlayer(game, owner), trainedLeader = game.leaderSkills!.assignments.find(a => a.owner === owner)!.leader;
const leader = trained ? p.leaders.find(l => l.id === trainedLeader && !l.dead)
: p.leaders.filter(l => !l.dead && l.id !== trainedLeader).sort((a, b) => b.strength - a.strength)[0];
const targetTrainer = game.leaderSkills!.assignments.find(a => a.owner === opponent)!.leader;
const target = pairedSkillsPlayer(game, opponent).leaders.filter(l => !l.dead && l.id !== targetTrainer).sort((a, b) => a.strength - b.strength)[0];
assert.ok(leader && target && leader.strength + dial > target.strength);
return [
{ actor: owner, action: { type: 'battlePlan', leader: leader.id, dial, support: game.advanced ? dial : 0, weapon: null, defense: null } },
{ actor: opponent, action: { type: 'battlePlan', leader: target.id, dial: 0, support: 0, weapon: null, defense: null } },
];
}
export function finishPairedSkillsBattle(game: Game, actions?: PairedChoamNexusSkillsStep[]): Game {
return advancePairedChoamSkills(game, g => !g.battle && pairedSkillsClean(g) && !g.pendingTreacheryDiscard, actions);
}
/** Original skill posture and native bank/support legs on the real revealed source. */
export function quotePairedSkillsBattle(game: Game) {
const battle = game.battle; assert.ok(battle?.revealed);
const side = (actor: string): ResolutionCombatant => {
const p = pairedSkillsPlayer(game, actor), view = viewGame(game, actor).battle!;
assert.ok(view.ownForces);
return { id: actor, faction: p.faction, ally: p.ally, spice: p.spice, hand: p.hand,
plan: battle.plans[actor], leader: p.leaders.find(l => l.id === battle.plans[actor].leader),
forces: view.ownForces, stronghold: view.strongholdEffects[actor],
leaderSkills: game.leaderSkills!.assignments.filter(a => a.owner === actor).map(a => ({
skill: a.skill, leader: a.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 : pairedSkillsPlayer(game, actor).ally!,
called: battle.traitorCalls[actor] ?? false, traitors: pairedSkillsPlayer(game, actor).traitors })),
participants: game.players,
physicalCards: [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand), ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? [])],
pendingAuditorPresent: !!game.pendingAuditor, pendingRetentionPresent: false });
}
export function pairedSkillsChoamCunningRequest(game: Game, actor: string, card: string): Action {
const offer = viewGame(game, actor).choamWorthless!.plays.find(p => p.source === 'nexus' && p.card.id === card && p.effect === 'kulon');
assert.ok(offer && offer.source === 'nexus' && !offer.blocked);
return { type: 'card', mode: 'choam', card, effect: 'kulon', nexus: offer.event };
}
/** Ordinary three-territory route, not No-Field or a special movement-card route.
* Kulon plus the original Planetologist range is tested through native actions. */
export function stagePairedSkillsPlanetologistRoute(game: Game, actor: string, staging?: string[]): { from: string; to: string; action: Action } {
const keys = TERRITORIES.filter(t => t.type === 'sand').flatMap(t => t.sectors.map(s => `${t.id}:${s}`));
let to: string | undefined;
const from = keys.find(origin => {
if (splitLocation(origin).sector === game.storm) return false;
to = keys.find(target => splitLocation(target).sector !== game.storm &&
gameDistance(game, origin, target, key => splitLocation(key).sector === game.storm) === 3);
return !!to;
});
assert.ok(from && to, 'A clear ordinary three-territory Planetologist route must exist');
placePairedSkillsForce(game, actor, from, 1, staging);
return { from, to, action: { type: 'move', planetologist: 'range', forces: { [from]: 1 }, ...splitLocation(to) } };
}
+383
View File
@@ -0,0 +1,383 @@
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 { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
import { pairedNexusLeaderSkillsProfile } from '../game/leader-skill-profile';
import { quoteBattleResolution, type ResolutionCombatant } from '../game/battle-resolution-quote';
import { traitorDeck } from '../game/traitors';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
export type PairedIxNexusSkillsStep = { actor: string; action: Action };
export type PairedIxNexusSkillsOptions = {
/** Genuine fresh lobby or original turn-one/phase-zero setup with zero assignments.
* Existing first hands, offers, actor IDs and stock are authoritative. */
initial?: Game;
advanced?: boolean;
program?: 'suboids' | 'faceDance';
skill?: LeaderSkillId;
band?: 'normal' | 'skilled';
dial?: number;
support?: number;
secondBattle?: boolean;
};
export type PairedIxNexusSkillsFixture = {
initial: Game;
offered: Game;
afterSetup: Game;
afterFirstStorm: Game;
beforeFirstMentat: Game;
firstMentatStep: PairedIxNexusSkillsStep;
afterFirstMentat: Game;
beforeAlliance: Game;
allianceActions: PairedIxNexusSkillsStep[];
beforeNexusDraw: Game;
drawActions: PairedIxNexusSkillsStep[];
afterNexusDraw: Game;
beforeBattle: Game;
battleStep: PairedIxNexusSkillsStep;
game: Game;
ixians: string;
tleilaxu: string;
opponent: string;
partners: [string, string];
trainer: string;
skill: LeaderSkillId;
band: 'normal' | 'skilled';
program: 'suboids' | 'faceDance';
kind: 'arrakeen' | 'sietch_tabr';
location: string;
replacementSource: string;
secondLocation?: string;
planActions: PairedIxNexusSkillsStep[];
actions: PairedIxNexusSkillsStep[];
staging: string[];
};
export function pairedIxNexusSkillsPlayer(game: Game, actor: string): Player {
const player = game.players.find(p => p.id === actor);
assert.ok(player, `Missing original E1 seat ${actor}`);
return player;
}
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
/** Control native shuffles only, never fabricate dealt cards. Non-word entropy
* forwards through the bound original method and always returns its input array. */
function entropy<T>(operation: () => T, rolls: number[] = []): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const original = crypto.getRandomValues.bind(crypto);
let cursor = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array) {
for (let i = 0; i < array.length; i++) array[i] = rolls[cursor++] ?? 0xffffffff;
} else Reflect.apply(original, crypto, [array]);
return array;
};
try { return operation(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
function shuffleRolls(source: string[], desired: string[]): number[] {
const working = [...source], rolls: number[] = [];
assert.deepEqual([...source].sort(), [...desired].sort());
for (let i = working.length - 1; i > 0; i--) {
const from = working.indexOf(desired[i]); assert.ok(from >= 0 && from <= i);
rolls.push(Math.floor((from + 0.5) * 0x100000000 / (i + 1)));
[working[i], working[from]] = [working[from], working[i]];
}
return rolls;
}
function skillRolls(selected: LeaderSkillId[]): number[] {
const source = LEADER_SKILL_CARDS.map(c => c.id);
const remainder = source.filter(c => !selected.includes(c));
const desired = selected.flatMap(c => [c, remainder.shift()!]);
return shuffleRolls(source, [...desired, ...remainder]);
}
/** Original default continuation declines native Face Dance; callers can stop
* at its real window before choosing a matching-stock reveal. Private plans remain explicit. */
export function nextPairedIxNexusSkillsStep(game: Game): PairedIxNexusSkillsStep | null {
if (clean(game) && game.nexusCards?.phase?.stage === 'drawing') {
const phase = game.nexusCards.phase, actor = phase.eligible.find(id => !phase.done.includes(id));
assert.ok(actor);
return { actor, action: { type: 'nexusCardChoice', turn: game.turn,
card: game.nexusCards.cards!.hands[actor] ?? null, choice: 'keep', ownRedraws: 0 } };
}
if (!game.phaseOpening && !game.response) {
const d = game.decision;
if (d?.kind === 'faceDance') return { actor: d.player, action: { type: 'decision', reveal: false } };
if (d?.kind === 'leaderSkillVisibility') return { actor: d.player,
action: { type: 'leaderSkillVisibility', event: d.event, hide: false } };
if (d?.kind === 'mentatQuestion') return { actor: d.player,
action: { type: 'decision', event: d.event, decline: true } };
if (d?.kind === 'rihani') return { actor: d.player,
action: { type: 'decision', event: d.event, draw: false } };
if (d?.kind === 'sukRescue') {
const best = d.options.map((o, choice) => ({ choice, count: o.normal + o.elite, kept: !!o.kept }))
.sort((a, b) => b.count - a.count || Number(b.kept) - Number(a.kept))[0];
return { actor: d.player, action: { type: 'decision', event: d.event, choice: best.choice } };
}
}
return nextStrongholdFactionsNativeStep(game);
}
export function stepPairedIxNexusSkills(game: Game, step = nextPairedIxNexusSkillsStep(game)): Game {
assert.ok(step, 'Original human plan or matching-stock Face Dance is required');
return entropy(() => applyAction(game, step.actor, step.action));
}
export function advancePairedIxNexusSkills(state: Game, until: (game: Game) => boolean,
actions: PairedIxNexusSkillsStep[] = []): Game {
let game = structuredClone(state);
for (let i = 0; i < 2400; i++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const step = nextPairedIxNexusSkillsStep(game); assert.ok(step, 'Native explicit human choice before requested boundary');
actions.push(structuredClone(step)); game = stepPairedIxNexusSkills(game, step);
}
throw Error('Paired E1 Nexus/Skills continuation did not reach its native boundary');
}
function reserveBoard(game: Game): void {
for (const p of game.players) {
p.reserves += Object.values(p.forces).reduce((sum, n) => sum + n, 0); p.forces = {};
if (p.elites) {
p.elites.reserves += Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0); p.elites.forces = {};
}
if (p.advisors) p.advisors = {};
}
}
function place(game: Game, actor: string, key: string, total: number, elite = 0): void {
const p = pairedIxNexusSkillsPlayer(game, actor);
assert.ok(total >= elite && p.reserves - (p.elites?.reserves ?? 0) >= total - elite && (p.elites?.reserves ?? 0) >= elite);
p.reserves -= total; p.forces[key] = (p.forces[key] ?? 0) + total;
if (elite) { p.elites!.reserves -= elite; p.elites!.forces[key] = (p.elites!.forces[key] ?? 0) + elite; }
}
function hold(game: Game, actor: string, kind: Card['kind'], staging: string[]): string {
const p = pairedIxNexusSkillsPlayer(game, actor), held = p.hand.find(c => c.kind === kind);
if (held) return held.id;
const index = game.deck.findIndex(c => c.kind === kind);
const donor = index < 0 ? game.players.find(other => other.id !== actor && other.hand.some(c => c.kind === kind)) : undefined;
assert.ok(index >= 0 || donor, 'Required original physical card has a deck/hand custodian');
assert.ok(p.hand.length < handLimit(p), 'Preserve actual starting hands and require real capacity');
const card = index >= 0 ? game.deck.splice(index, 1)[0] : donor!.hand.splice(donor!.hand.findIndex(c => c.kind === kind), 1)[0];
p.hand.push(card); staging.push(`Conserved ${card.id}: ${donor?.id ?? 'original remaining deck'} → ${actor} hand after native deals`);
return card.id;
}
function orderSpice(game: Game, kinds: ('land' | 'worm')[], staging: string[]): void {
const remaining = [...game.spiceDeck];
const ordered = kinds.map(kind => {
const at = remaining.findIndex(c => kind === 'land' ? 'territory' in c : 'worm' in c && !c.greatMaker && !c.suppressed);
assert.ok(at >= 0); return remaining.splice(at, 1)[0];
});
game.spiceDeck = [...ordered, ...remaining];
staging.push(`Conserved original undealt Spice order: ${kinds.join(', ')}; not natural shuffle history`);
}
/** Original 47/14/12 native setup → Storm → real Mentat → conserved worm →
* real alliance → real closing native draws. Board/card relocation is explicit;
* wallets, phases, turns, effects, stock and sealed plans are never assigned. */
export function createPairedIxNexusSkillsFixture(options: PairedIxNexusSkillsOptions = {}): PairedIxNexusSkillsFixture {
const advanced = options.advanced ?? options.initial?.advanced ?? true;
const program = options.program ?? 'suboids', band = options.band ?? 'skilled';
let game = options.initial ? structuredClone(options.initial)
: createGame('PAIREDIXNEXUSSKILLS', newPlayer('ixians', 'Ixians', 'ixians'), advanced, ['ix']);
if (!options.initial) for (const faction of ['tleilaxu', 'guild', 'emperor'] as const)
joinGame(game, newPlayer(faction, faction, faction));
assert.equal(game.advanced, advanced); assert.deepEqual(game.expansions, ['ix']);
assert.ok(!game.techTokens && !game.strongholdCards && !game.homeworlds);
assert.ok(game.players.length >= 4 && game.players.length <= 6,
'Both native Cunning draws require two separate classic alliance partners; use four through six original seats');
const ixians = game.players.find(p => p.faction === 'ixians')?.id;
const tleilaxu = game.players.find(p => p.faction === 'tleilaxu')?.id;
assert.ok(ixians && tleilaxu);
const classics = game.players.filter(p => p.id !== ixians && p.id !== tleilaxu);
const opponent = classics.find(p => p.faction === 'guild')?.id ?? classics[0].id;
const partners: [string, string] = [classics[0].id, classics[1].id];
const actions: PairedIxNexusSkillsStep[] = [], staging: string[] = [];
const requested = options.skill ?? 'suk-graduate';
const passives: LeaderSkillId[] = (['sandmaster', 'smuggler', 'rihani-decipherer', 'prana-bindu-adept', 'killer-medic', 'master-of-assassins'] as LeaderSkillId[])
.filter(skill => skill !== requested);
const selected = game.players.map(p => p.id === ixians ? requested : passives.shift()!);
const supplied = !!options.initial;
const initial = structuredClone(game);
if (game.status === 'lobby') {
assert.ok(!game.leaderSkills, 'Original all14 custody cannot be replaced');
game.nexusCards ??= { cards: null, phase: null };
assert.deepEqual(game.nexusCards, { cards: null, phase: null });
for (const p of game.players) if (!p.ready) {
const step: PairedIxNexusSkillsStep = { actor: p.id, action: { type: 'ready' } };
actions.push(step); game = stepPairedIxNexusSkills(game, step);
}
game = entropy(() => initializeLeaderSkillsGameForAudit(game), skillRolls(selected));
staging.push('Scoped original all14 shuffle selects native offers before starting Treachery deals; no offers are replaced');
}
assert.ok(pairedNexusLeaderSkillsProfile(game));
assert.equal(game.status, 'setup'); assert.equal(game.turn, 1); assert.equal(game.phase, 0);
assert.equal(game.leaderSkills!.assignments.length, 0);
let offered = structuredClone(game), trainer = '';
for (let i = 0; game.status === 'setup' && i < 240; i++) {
let step: PairedIxNexusSkillsStep;
if (game.setupStage === 'leaderSkills') {
offered = trainer ? offered : structuredClone(game);
const actor: string = Object.keys(game.leaderSkills!.offers)[0];
const offer: NonNullable<Game['leaderSkills']>['offers'][string] = game.leaderSkills!.offers[actor];
const projection = viewGame(game, actor).leaderSkills!;
const legal: LeaderSkillId[] = offer.cards.filter(c => !projection.unavailableSkills?.[c]);
const desired: LeaderSkillId = selected[game.players.findIndex(p => p.id === actor)];
const skill: LeaderSkillId | undefined = actor === ixians && options.skill ? legal.find(c => c === options.skill)
: !supplied && legal.includes(desired) ? desired : legal[0];
assert.ok(skill, 'Explicit requested skill must genuinely exist in the original saved offer');
const eligible = new Set(projection.eligibleLeaders.map(l => l.id));
const leader = pairedIxNexusSkillsPlayer(game, actor).leaders.filter(l => eligible.has(l.id)).sort((a, b) => b.strength - a.strength)[0];
assert.ok(leader);
if (actor === ixians) trainer = leader.id;
step = { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: leader.id } };
} else {
const native = nextPairedIxNexusSkillsStep(game); assert.ok(native); step = native;
if (!supplied && game.setupStage === 'traitors' && step.action.type === 'traitor') {
const choices = pairedIxNexusSkillsPlayer(game, step.actor).traitorChoices;
const card = choices.find(id => id !== trainer); assert.ok(card); step.action.leader = card;
}
}
actions.push(structuredClone(step));
if (!supplied && game.setupStage === 'forces') {
const held = new Set(game.players.flatMap(p => p.traitors));
const source = traitorDeck(game.players, true).filter(id => !held.has(id));
assert.ok(source.includes(trainer), 'Original trained Ix identity must remain undealt');
game = entropy(() => applyAction(game, step.actor, step.action), shuffleRolls(source, [trainer, ...source.filter(id => id !== trainer)]));
} else game = supplied ? applyAction(game, step.actor, step.action) : stepPairedIxNexusSkills(game, step);
}
assert.equal(game.status, 'playing');
const afterSetup = structuredClone(game);
const assignment = game.leaderSkills!.assignments.find(a => a.owner === ixians)!;
trainer = assignment.leader; const skill = assignment.skill;
game = advancePairedIxNexusSkills(game, g => g.phase === 1 && clean(g), actions);
const afterFirstStorm = structuredClone(game);
orderSpice(game, ['land', 'land'], staging);
game = advancePairedIxNexusSkills(game, g => g.phase === 5 && clean(g), actions);
reserveBoard(game); staging.push('After native starting placements: conserved board counters returned to original reserves before first movement completion');
game = advancePairedIxNexusSkills(game, g => g.phase === 8 && clean(g), actions);
let beforeFirstMentat!: Game, firstMentatStep!: PairedIxNexusSkillsStep;
while (game.turn === 1) {
const step = nextPairedIxNexusSkillsStep(game); assert.ok(step);
const before = structuredClone(game); actions.push(structuredClone(step)); game = stepPairedIxNexusSkills(game, step);
if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = step; }
}
const afterFirstMentat = structuredClone(game);
game = advancePairedIxNexusSkills(game, g => g.phase === 1 && clean(g), actions);
orderSpice(game, ['worm', 'land', 'land'], staging);
game = advancePairedIxNexusSkills(game, g => !!g.nexus && !g.spiceWindow && !g.spiceResolution && clean(g), actions);
const beforeAlliance = structuredClone(game);
const allianceActions: PairedIxNexusSkillsStep[] = partners.map((actor, i) => ({ actor, action: { type: 'alliance', target: partners[1 - i] } }));
for (const step of allianceActions) { actions.push(step); game = stepPairedIxNexusSkills(game, step); }
game = advancePairedIxNexusSkills(game, g => g.nexusCards?.phase?.stage === 'drawing', actions);
const cards = game.nexusCards!.cards!;
assert.ok(cards.deck.includes('ixians') && cards.deck.includes('tleilaxu'));
cards.deck = ['ixians', 'tleilaxu', ...cards.deck.filter(c => c !== 'ixians' && c !== 'tleilaxu')];
staging.push('Order conserved unused Ix/Tleilaxu Nexus singletons; only real closing draws grant the two native hands');
const beforeNexusDraw = structuredClone(game);
const drawActions: PairedIxNexusSkillsStep[] = [ixians, tleilaxu].map(actor => ({ actor,
action: { type: 'nexusCardChoice', turn: game.turn, card: cards.hands[actor], choice: 'draw', ownRedraws: 0 } }));
for (const step of drawActions) { actions.push(step); game = stepPairedIxNexusSkills(game, step); }
game = advancePairedIxNexusSkills(game, g => g.nexusCards?.phase?.stage !== 'drawing', actions);
const afterNexusDraw = structuredClone(game);
game = advancePairedIxNexusSkills(game, g => g.phase === 5 && clean(g), actions);
reserveBoard(game);
const kind = program === 'faceDance' ? 'sietch_tabr' : 'arrakeen';
const location = `${kind}:${territory(kind).sectors[0]}`, replacementSource = `arrakeen:${territory('arrakeen').sectors[0]}`;
place(game, ixians, location, 6, 3); place(game, opponent, location, 6);
if (program === 'faceDance') place(game, tleilaxu, replacementSource, 3);
const secondLocation = options.secondBattle ? `carthag:${territory('carthag').sectors[0]}` : undefined;
if (secondLocation) { place(game, ixians, secondLocation, 3); place(game, opponent, secondLocation, 2); }
staging.push('Before native movement completion: conserved six Ix counters (three Cyborg/three Suboid), six ordinary opponents and optional physical replacement/second-battle sources; no phase, wallet or stock mutation');
const ix = pairedIxNexusSkillsPlayer(game, ixians), stock = pairedIxNexusSkillsPlayer(game, tleilaxu).faceDancers!;
const untrained = (p: Player) => p.leaders.filter(l => !l.dead && !l.usedAt && !l.gholaBy && !l.capturedBy &&
!game.leaderSkills!.assignments.some(a => a.leader === l.id));
const leader = band === 'skilled' ? ix.leaders.find(l => l.id === trainer)! : untrained(ix)
.filter(l => program !== 'faceDance' || stock.some(c => c.leader === l.id && !c.revealed)).sort((a, b) => b.strength - a.strength)[0];
assert.ok(leader && (program !== 'faceDance' || stock.some(c => c.leader === leader.id && !c.revealed)),
'Infeasible original stock: this band requires a matching living original Ix disc; no stock is fabricated or exchanged');
const enemy = untrained(pairedIxNexusSkillsPlayer(game, opponent)).sort((a, b) => a.strength - b.strength)[0]; assert.ok(enemy);
const weapon = skill === 'warmaster' ? hold(game, ixians, 'worthless', staging)
: program === 'faceDance' ? hold(game, ixians, 'projectile', staging) : null;
const defense = program === 'faceDance' ? hold(game, ixians, 'shield', staging) : null;
game = advancePairedIxNexusSkills(game, g => g.phase === 6 && clean(g), actions);
const beforeBattle = structuredClone(game), actor = game.active!; assert.ok(actor === ixians || actor === opponent);
const battleStep: PairedIxNexusSkillsStep = { actor, action: { type: 'chooseBattle', territory: kind, target: actor === ixians ? opponent : ixians } };
actions.push(battleStep); game = stepPairedIxNexusSkills(game, battleStep);
const planActions: PairedIxNexusSkillsStep[] = [
{ actor: ixians, action: { type: 'battlePlan', leader: leader.id, dial: options.dial ?? (advanced ? 4 : 6),
support: advanced ? options.support ?? 1 : 0, weapon, defense } },
{ actor: opponent, action: { type: 'battlePlan', leader: enemy.id, dial: 0, support: 0, weapon: null, defense: null } },
];
return { initial, offered, afterSetup, afterFirstStorm, beforeFirstMentat, firstMentatStep, afterFirstMentat,
beforeAlliance, allianceActions, beforeNexusDraw, drawActions, afterNexusDraw, beforeBattle, battleStep,
game, ixians, tleilaxu, opponent, partners, trainer, skill, band, program, kind, location, replacementSource,
secondLocation, planActions, actions, staging };
}
export function preparePairedIxNexusSkillsBattle(f: PairedIxNexusSkillsFixture, state = f.game): Game {
let game = structuredClone(state);
for (let i = 0; i < 160; i++) {
const step = nextPairedIxNexusSkillsStep(game);
if (!step) { assert.ok(game.battle && !game.battle.revealed); return game; }
if (step.action.type === 'leaderSkillVisibility') step.action.hide = step.actor === f.ixians && f.band === 'skilled';
game = stepPairedIxNexusSkills(game, step);
}
throw Error('Paired E1 native skill posture did not reach private plans');
}
export function beginPairedIxNexusSkillsCunning(f: PairedIxNexusSkillsFixture, state = preparePairedIxNexusSkillsBattle(f)): Game {
const offer = viewGame(state, f.ixians).nexusSuboids?.offer;
assert.ok(offer); assert.equal(offer.blocked, null);
return stepPairedIxNexusSkills(state, { actor: f.ixians, action: { type: 'nexusSuboids', event: offer.event } });
}
export function revealPairedIxNexusSkillsBattle(f: PairedIxNexusSkillsFixture,
options: { state?: Game; cunning?: boolean; plans?: PairedIxNexusSkillsStep[] } = {}): Game {
let game = preparePairedIxNexusSkillsBattle(f, options.state ?? f.game);
if (options.cunning) game = advancePairedIxNexusSkills(beginPairedIxNexusSkillsCunning(f, game), clean);
for (const step of options.plans ?? f.planActions) {
game = stepPairedIxNexusSkills(game, step);
while (game.response || game.phaseOpening || game.decision?.kind === 'fullPlanOffer') game = stepPairedIxNexusSkills(game);
}
assert.ok(game.battle?.revealed); return game;
}
export function finishPairedIxNexusSkillsBattle(state: Game,
stop: 'losses' | 'rescue' | 'substitution' | 'cards' | 'faceDance' | 'complete' = 'complete'): Game {
const kinds = { losses: 'battleLosses', rescue: 'sukRescue', substitution: 'ixSubstitution', cards: 'battleCards', faceDance: 'faceDance', complete: '' };
return advancePairedIxNexusSkills(state, g => g.decision?.kind === kinds[stop] || !g.battle && clean(g) && !g.pendingTreacheryDiscard);
}
export function finishPairedIxNexusSkillsTurn(state: Game): Game {
return advancePairedIxNexusSkills(state, g => g.turn > state.turn);
}
export function choosePairedIxNexusSkillsSecondBattle(f: PairedIxNexusSkillsFixture, state: Game): Game {
assert.ok(f.secondLocation);
let game = advancePairedIxNexusSkills(state, g => g.phase === 6 && clean(g) && !!g.active);
const actor = game.active!; assert.ok(actor === f.ixians || actor === f.opponent);
game = stepPairedIxNexusSkills(game, { actor, action: { type: 'chooseBattle', territory: 'carthag', target: actor === f.ixians ? f.opponent : f.ixians } });
return preparePairedIxNexusSkillsBattle(f, game);
}
export function preparePairedIxNexusSkillsExpiryBattle(f: PairedIxNexusSkillsFixture, state: Game): Game {
let game = advancePairedIxNexusSkills(state, g => g.phase === 5 && clean(g));
reserveBoard(game); place(game, f.ixians, f.location, 3); place(game, f.tleilaxu, f.location, 2);
game = advancePairedIxNexusSkills(game, g => g.phase === 6 && clean(g));
const actor = game.active!; assert.ok(actor === f.ixians || actor === f.tleilaxu);
game = stepPairedIxNexusSkills(game, { actor, action: { type: 'chooseBattle', territory: f.kind, target: actor === f.ixians ? f.tleilaxu : f.ixians } });
return preparePairedIxNexusSkillsBattle(f, game);
}
/** Actual skill posture and actual native Cunning force flags, not inferred cap arithmetic. */
export function quotePairedIxNexusSkillsBattle(game: Game) {
const battle = game.battle; assert.ok(battle?.revealed);
const side = (actor: string): ResolutionCombatant => {
const p = pairedIxNexusSkillsPlayer(game, actor), view = viewGame(game, actor).battle!; assert.ok(view.ownForces);
return { id: actor, faction: p.faction, ally: p.ally, spice: p.spice, hand: p.hand, plan: battle.plans[actor],
leader: p.leaders.find(l => l.id === battle.plans[actor].leader), forces: view.ownForces,
stronghold: view.strongholdEffects[actor], leaderSkills: game.leaderSkills!.assignments.filter(a => a.owner === actor)
.map(a => ({ skill: a.skill, leader: a.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 : pairedIxNexusSkillsPlayer(game, actor).ally!,
called: battle.traitorCalls[actor] ?? false, traitors: pairedIxNexusSkillsPlayer(game, actor).traitors })),
participants: game.players, physicalCards: [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand)],
pendingAuditorPresent: false, pendingRetentionPresent: false });
}
@@ -0,0 +1,301 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { presenceAt } from '../game/force-presence';
import { territory } from '../game/board';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
import { reserveShipmentCost } from '../game/shipment-price';
import { isPlanetologistBattleSpecialCard } from '../game/leader-skill-combat';
import {
advancePairedChoamSkills, advancePairedChoamSkillsToPhase, allowPairedChoamSkills,
assertPairedChoamSkillsCustody as custody, completePairedChoamSkillsSetup,
createPairedChoamNexusSkillsFixture, finishPairedSkillsBattle, holdPairedSkillsCard,
initializePairedChoamNexusSkills, openPairedSkillsBattle, pairedSkillsBattlePlans,
pairedSkillsChoamCunningRequest, pairedSkillsClean, pairedSkillsPhysicalCards,
pairedSkillsPlayer as player, pairedSkillsRicheseCunningRequest, pairedSkillsSnapshot,
placePairedSkillsForce, revealPairedSkillsRichese, stagePairedSkillsPlanetologistRoute,
quotePairedSkillsBattle,
stepPairedChoamSkills, type PairedChoamNexusSkillsFixture,
} from './fixture-paired-choam-nexus-skills';
function reject(game: Game, actor: string, action: Action): void {
const before = pairedSkillsSnapshot(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before, 'Illegal composition preserves original spice, cards, markers, forces and skill custody');
}
function assertPair(f: PairedChoamNexusSkillsFixture, shipped: Game, faceup: number): void {
const request = pairedSkillsRicheseCunningRequest(f), before = player(f.game, f.richese), after = player(shipped, f.richese);
assert.equal(after.spice, before.spice - 1);
assert.equal(player(shipped, f.guild).spice, player(f.game, f.guild).spice + 1, 'One original one-marker Stronghold tariff, not eight counter invoices');
assert.equal(after.reserves, before.reserves - faceup);
assert.equal(after.forces[f.location] ?? 0, faceup);
assert.equal(after.noField!.deployed!.tokenId, request.noField);
assert.equal(after.noField!.lastShipped, request.noField);
assert.equal(presenceAt(after, 'habbanya_ridge_sietch'), faceup + 1, 'The second group is one concealed marker, not extra ordinary force identity');
assert.equal(shipped.nexusCards!.cards!.hands[f.richese], null);
assert.equal(shipped.nexusRicheseCunningLast!.stage, 'shipped');
assert.equal(after.shipped, true);
assert.deepEqual(shipped.leaderSkills!.assignments, f.game.leaderSkills!.assignments, 'Cunning neither borrows nor fabricates a companion skill');
assert.deepEqual(pairedSkillsPhysicalCards(shipped), pairedSkillsPhysicalCards(f.afterSetup));
custody(shipped);
}
for (const advanced of [false, true]) void test(`paired E2 ${advanced ? 'Advanced' : 'Basic'}: actual previous marker, all14 skill assignments, worm/alliance/closing draws and single physical pair invoice`, () => {
const f = createPairedChoamNexusSkillsFixture({ advanced });
assert.equal(f.initial.richeseCache!.length, 10);
assert.equal(pairedSkillsPhysicalCards(f.initial).length, 45, 'Original CHOAM35 plus separate Richese10, not canonical47 or a mixed deck');
for (const p of f.initial.players) assert.equal(p.hand.length, 1, 'Native starting cards are dealt before original skill offers');
assert.equal(f.afterSetup.leaderSkills!.assignments.length, 4);
assert.equal(f.beforeFirstMentat.turn, 1);
assert.equal(f.afterFirstMentat.turn, 2);
assert.equal(f.beforeAlliance.nexus, true);
assert.equal(player(f.beforeClosingDraw, f.guild).ally, f.observer);
assert.equal(player(f.beforeClosingDraw, f.observer).ally, f.guild);
for (const draw of f.closingDraws) {
assert.equal(draw.before.nexusCards!.cards!.hands[draw.step.actor], null);
assert.equal(draw.after.nexusCards!.cards!.hands[draw.step.actor], player(draw.after, draw.step.actor).faction);
}
const previous = player(f.game, f.richese).noField!.tokens.find(t => t.value === 0)!;
assert.equal(player(f.firstShipment.after, f.richese).spice, player(f.firstShipment.before, f.richese).spice - 1);
assert.equal(player(f.firstShipment.after, f.richese).reserves, player(f.firstShipment.before, f.richese).reserves);
assert.equal(player(f.game, f.richese).noField!.lastShipped, previous.id);
const request = pairedSkillsRicheseCunningRequest(f);
reject(f.game, f.richese, { ...request, noField: previous.id });
reject(f.game, f.richese, { ...request, revealedToken: previous.id });
reject(f.game, f.richese, { ...request, revealedToken: request.noField });
reject(f.game, f.richese, { ...request, smuggler: true });
reject(f.game, f.richese, { ...request, amount: 8 });
holdPairedSkillsCard(f.game, f.observer, card => card.effect === 'karama', f.staging);
const declared = applyAction(f.game, f.richese, request);
assert.equal(declared.response?.kind, 'richeseNoField');
assert.equal(declared.pendingShipment!.cost, 1);
assert.equal(declared.pendingShipment!.amount, 1);
assert.equal(declared.pendingShipment!.noFieldSkillProof, undefined, 'Original signed pair, not an invented ordinary skill proof');
assert.equal(declared.pendingShipment!.smugglerCompanion, undefined);
assert.deepEqual(player(declared, f.richese).forces, player(f.game, f.richese).forces);
assert.equal(player(declared, f.richese).spice, player(f.game, f.richese).spice);
const changed = pairedSkillsSnapshot(declared);
changed.pendingShipment!.cost = 0;
const responder = changed.players.find(p => !changed.response!.passed.includes(p.id))!.id;
reject(changed, responder, { type: 'passResponse' });
const shipped = allowPairedChoamSkills(pairedSkillsSnapshot(declared));
assertPair(f, shipped, 5);
reject(shipped, f.richese, request);
const continued = allowPairedChoamSkills(shipped);
assert.equal(player(continued, f.richese).spice, player(shipped, f.richese).spice, 'Settled response cannot repeat or refund the invoice');
assert.equal(player(continued, f.guild).spice, player(shipped, f.guild).spice);
});
void test('paired Richese immediate group is reserve-capped; voluntary reveal cannot invent concealed counters and next native shipment respects original marker history', () => {
const f = createPairedChoamNexusSkillsFixture({ advanced: false, reserveCap: 2 });
const shipped = allowPairedChoamSkills(applyAction(f.game, f.richese, pairedSkillsRicheseCunningRequest(f)));
assertPair(f, shipped, 2);
const revealed = revealPairedSkillsRichese(shipped, f.richese);
assert.equal(player(revealed, f.richese).noField!.deployed, null);
assert.equal(player(revealed, f.richese).forces[f.location], 2);
assert.equal(player(revealed, f.richese).reserves, 0);
const previous = player(revealed, f.richese).noField!.lastShipped!;
let next = advancePairedChoamSkills(revealed, g => g.turn === 3 && g.phase === 1 && pairedSkillsClean(g));
// Preserve already played discards; order only unused native land cards.
const land = next.spiceDeck.filter(c => 'territory' in c).slice(0, 2);
assert.equal(land.length, 2);
for (const [at, card] of land.entries()) next.spiceDeck.splice(at, 0, next.spiceDeck.splice(next.spiceDeck.indexOf(card), 1)[0]);
next = advancePairedChoamSkills(next, g => g.turn === 3 && g.phase === 5 && g.active === f.richese && pairedSkillsClean(g));
reject(next, f.richese, { type: 'ship', noField: previous, event: player(next, f.richese).noFieldEvent, territory: 'polar_sink', sector: 0 });
const zero = player(next, f.richese).noField!.tokens.find(t => t.value === 0)!;
const accepted = allowPairedChoamSkills(applyAction(next, f.richese, { type: 'ship', noField: zero.id, event: player(next, f.richese).noFieldEvent, territory: 'polar_sink', sector: 0 }));
assert.equal(player(accepted, f.richese).noField!.lastShipped, zero.id);
assert.equal(player(accepted, f.richese).noField!.deployed!.tokenId, zero.id);
assert.equal(player(accepted, f.richese).spice, player(next, f.richese).spice - 2);
assert.equal(player(accepted, f.richese).forces['polar_sink:0'], 18);
custody(accepted);
});
void test('paired skills Cunning stopped by physical Karama spends only Nexus; ordinary shipment remains a separate single payment', () => {
const f = createPairedChoamNexusSkillsFixture();
const karama = holdPairedSkillsCard(f.game, f.guild, c => c.kind === 'special' && c.effect === 'karama', f.staging);
const before = pairedSkillsSnapshot(f.game), request = pairedSkillsRicheseCunningRequest(f);
const declared = applyAction(f.game, f.richese, request);
const stopped = allowPairedChoamSkills(applyAction(declared, f.guild, { type: 'card', mode: 'cancel', card: karama.id }));
assert.equal(stopped.nexusRicheseCunningLast!.stage, 'stopped');
assert.equal(stopped.nexusCards!.cards!.hands[f.richese], null);
for (const p of before.players) assert.equal(player(stopped, p.id).spice, p.spice);
assert.deepEqual(player(stopped, f.richese).forces, player(before, f.richese).forces);
assert.equal(player(stopped, f.richese).reserves, player(before, f.richese).reserves);
assert.deepEqual(player(stopped, f.richese).noField, player(before, f.richese).noField);
assert.equal(player(stopped, f.richese).shipped, false);
assert.equal(stopped.discard.filter(c => c.id === karama.id).length, 1);
reject(stopped, f.richese, request);
const ordinary = allowPairedChoamSkills(applyAction(stopped, f.richese, { type: 'ship', amount: 1, territory: 'habbanya_ridge_sietch', sector: Number(f.location.split(':')[1]) }));
assert.equal(player(ordinary, f.richese).spice, player(stopped, f.richese).spice - 1);
assert.equal(player(ordinary, f.guild).spice, player(stopped, f.guild).spice + 1);
assert.equal(player(ordinary, f.richese).forces[f.location], 1);
assert.deepEqual(ordinary.leaderSkills!.assignments, before.leaderSkills!.assignments);
custody(ordinary);
});
void test('genuine unpaid Richese pair preserves both original markers, physical Nexus, reserves and skill assignment', () => {
const f = createPairedChoamNexusSkillsFixture();
const amount = player(f.game, f.richese).spice;
assert.ok(amount > 0);
const unpaid = applyAction(f.game, f.richese, { type: 'bribe', target: f.guild, amount });
assert.equal(player(unpaid, f.richese).spice, 0);
assert.equal(player(unpaid, f.guild).bribes, player(f.game, f.guild).bribes + amount, 'Wallet is depleted by a genuine native bribe, never assigned');
const before = pairedSkillsSnapshot(unpaid);
reject(unpaid, f.richese, pairedSkillsRicheseCunningRequest(f, unpaid));
assert.equal(unpaid.nexusCards!.cards!.hands[f.richese], 'richese');
assert.deepEqual(player(unpaid, f.richese).noField, player(before, f.richese).noField);
custody(unpaid);
});
for (const advanced of [false, true]) for (const trained of [false, true])
void test(`paired ${advanced ? 'Advanced' : 'Basic'} voluntary reveal → ordinary ${trained ? 'skilled' : 'normal'} Suk rescue; native CHOAM support income stays separate from Collection`, () => {
const f = createPairedChoamNexusSkillsFixture({ advanced });
let game = allowPairedChoamSkills(applyAction(f.game, f.richese, pairedSkillsRicheseCunningRequest(f)));
game = revealPairedSkillsRichese(game, f.richese);
assert.equal(player(game, f.richese).noField!.deployed, null);
assert.equal(player(game, f.richese).forces[f.location], 8);
assert.equal(player(game, f.richese).reserves, 12);
placePairedSkillsForce(game, f.guild, f.location, 3, f.staging);
placePairedSkillsForce(game, f.choam, `arrakeen:${territory('arrakeen').sectors[0]}`, 1, f.staging);
game = openPairedSkillsBattle(game, f.richese, f.guild, { band: trained ? 'skilled' : 'normal' });
const plans = pairedSkillsBattlePlans(game, f.richese, f.guild, 2, trained);
assert.equal(plans[0].action.leader === f.trainers.richese, trained);
for (const next of plans) game = allowPairedChoamSkills(stepPairedChoamSkills(game, next));
assert.ok(game.battle?.revealed);
const revealed = pairedSkillsSnapshot(game), quote = quotePairedSkillsBattle(game);
assert.equal(quote.result, 'normal');
assert.equal(quote.winner, f.richese);
assert.equal(quote.sukGraduate!.mode, trained ? 'skilled' : 'normal');
assert.equal(viewGame(game, f.richese).battle!.ownForces!.normal, 8, 'Both marker groups now have ordinary native physical force identity');
const payment = quote.payments.find(p => p.player === f.richese);
assert.equal(payment?.ownPayment ?? 0, advanced ? 2 : 0);
assert.equal(payment?.bankSupport ?? 0, 0);
if (advanced) {
assert.equal(quote.choamIncome!.owner, f.choam);
assert.ok(quote.choamIncome!.amount > 0);
} else assert.equal(quote.choamIncome, null);
game = finishPairedSkillsBattle(game);
assert.equal(game.lastBattleContext!.winner, f.richese);
assert.equal(game.lastBattleContext!.result, 'normal');
assert.equal(player(game, f.richese).forces[f.location], trained ? 7 : 6);
assert.equal(player(game, f.richese).reserves, 13, 'Skilled Suk keeps one rescued casualty on its original sector and returns the other; normal Suk returns one');
assert.equal(player(game, f.richese).tanks, trained ? 0 : 1);
assert.equal(player(game, f.guild).tanks, 3, 'Declining a traitor call still loses all opposing physical forces in an ordinary battle');
game = advancePairedChoamSkillsToPhase(game, 7);
const collection = quoteSpiceCollection({ ...game, players: game.players.map(p => ({ ...p, spice: 0 })) }).receipts;
assert.equal(collection.find(r => r.player === f.choam)!.strongholds, advanced ? 2 : 0, 'Native Arrakeen Collection is a separate later credit, not battle support income');
for (const p of revealed.players) {
const support = quote.payments.find(r => r.player === p.id)?.ownPayment ?? 0;
const income = quote.choamIncome?.owner === p.id ? quote.choamIncome.amount : 0;
const city = collection.find(r => r.player === p.id);
assert.equal(player(game, p.id).spice, p.spice - support + income + (city?.strongholds ?? 0) + (city?.collected ?? 0));
}
custody(game);
});
void test('CHOAM own Cunning converts one original Special into printed Kulon fuel once; ordinary Planetologist route consumes physical forces, not another card', () => {
const f = createPairedChoamNexusSkillsFixture({ advanced: false });
let game = advancePairedChoamSkills(f.movementStart, g => g.phase === 5 && g.active === f.choam && pairedSkillsClean(g));
const assignment = game.leaderSkills!.assignments.find(a => a.owner === f.choam)!;
assert.equal(assignment.skill, 'planetologist');
const fuel = holdPairedSkillsCard(game, f.choam, isPlanetologistBattleSpecialCard, f.staging);
holdPairedSkillsCard(game, f.guild, card => card.effect === 'karama', f.staging);
const route = stagePairedSkillsPlanetologistRoute(game, f.choam, f.staging);
const request = pairedSkillsChoamCunningRequest(game, f.choam, fuel.id), before = pairedSkillsSnapshot(game);
reject(before, f.choam, route.action);
reject(before, f.choam, { ...request, nexus: 'stale-original-offer' });
game = applyAction(game, f.choam, request);
assert.equal(game.response?.kind, 'choamWorthless');
assert.equal(game.nexusCards!.cards!.hands[f.choam], null);
assert.deepEqual(player(game, f.choam).hand.find(c => c.id === fuel.id), fuel, 'Original response retains printed fuel custody until resolution');
game = allowPairedChoamSkills(game);
assert.equal(game.nexusChoamLast!.stage, 'complete');
assert.equal(game.discard.filter(c => c.id === fuel.id).length, 1);
assert.equal(player(game, f.choam).hand.some(c => c.id === fuel.id), false);
assert.equal(game.choamMovement!.bonus, 1);
assert.deepEqual(game.leaderSkills!.assignments, before.leaderSkills!.assignments);
reject(game, f.choam, request);
reject(game, f.choam, { ...route.action, noField: 'not-an-ordinary-route' });
const moved = allowPairedChoamSkills(applyAction(game, f.choam, route.action));
assert.equal(player(moved, f.choam).forces[route.from] ?? 0, 0);
assert.equal(player(moved, f.choam).forces[route.to], 1);
assert.equal(player(moved, f.choam).moved, 1);
assert.equal(moved.discard.filter(c => c.id === fuel.id).length, 1);
for (const p of before.players) assert.equal(player(moved, p.id).spice, p.spice);
custody(moved);
});
void test('canceling actual CHOAM Cunning consumes Karama and Nexus, retains the original skill-eligible Special and gives no Kulon route', () => {
const f = createPairedChoamNexusSkillsFixture({ advanced: false });
let game = advancePairedChoamSkills(f.movementStart, g => g.phase === 5 && g.active === f.choam && pairedSkillsClean(g));
const fuel = holdPairedSkillsCard(game, f.choam, isPlanetologistBattleSpecialCard, f.staging);
const karama = holdPairedSkillsCard(game, f.guild, c => c.kind === 'special' && c.effect === 'karama' && c.id !== fuel.id, f.staging);
const route = stagePairedSkillsPlanetologistRoute(game, f.choam, f.staging), before = pairedSkillsSnapshot(game);
const request = pairedSkillsChoamCunningRequest(game, f.choam, fuel.id);
game = applyAction(game, f.choam, request);
game = allowPairedChoamSkills(applyAction(game, f.guild, { type: 'card', mode: 'cancel', card: karama.id }));
assert.equal(game.nexusChoamLast!.stage, 'canceled');
assert.equal(game.nexusCards!.cards!.hands[f.choam], null);
assert.deepEqual(player(game, f.choam).hand.find(c => c.id === fuel.id), fuel);
assert.equal(game.discard.some(c => c.id === fuel.id), false);
assert.equal(game.discard.filter(c => c.id === karama.id).length, 1);
assert.equal(game.choamMovement?.bonus ?? 0, 0);
assert.deepEqual(game.leaderSkills!.assignments, before.leaderSkills!.assignments);
reject(game, f.choam, route.action);
reject(game, f.choam, request);
for (const p of before.players) assert.equal(player(game, p.id).spice, p.spice);
custody(game);
});
void test('original authenticated setup preserves already-dealt native hands, saved offers and IDs through genuine native continuation', () => {
const lobby = createGame('AUTHPAIREDE2SKILLS', newPlayer('auth-r', 'R', 'richese'), true, ['choam']);
joinGame(lobby, newPlayer('auth-c', 'C', 'choam'));
joinGame(lobby, newPlayer('auth-g', 'G', 'guild'));
joinGame(lobby, newPlayer('auth-a', 'A', 'atreides'));
const setup = initializePairedChoamNexusSkills({ initial: lobby }), saved = pairedSkillsSnapshot(setup);
const f = createPairedChoamNexusSkillsFixture({ initial: setup });
assert.deepEqual(setup, saved);
assert.deepEqual(f.initial, saved, 'Actual initialized offers and first deal are not recreated');
for (const p of saved.players) assert.deepEqual(player(f.afterSetup, p.id).hand, p.hand);
assert.deepEqual([f.richese, f.choam, f.guild, f.observer], ['auth-r', 'auth-c', 'auth-g', 'auth-a']);
const richeseOffer = setup.leaderSkills!.offers[f.richese];
const absent = setup.leaderSkills!.deck.find(skill => !richeseOffer.cards.includes(skill))!;
assert.throws(() => createPairedChoamNexusSkillsFixture({ initial: setup, richeseSkill: absent }));
assert.deepEqual(setup, saved, 'An unavailable requested skill cannot replace saved offers');
for (const difficulty of DIFFICULTIES) {
const view = viewGame(f.game, f.richese);
view.players.find(p => p.id === f.richese)!.bot = difficulty;
const action = botActions(view)[0];
assert.ok(action?.type === 'ship' && action.noField && action.revealedToken, difficulty);
const shipped = allowPairedChoamSkills(applyAction(f.game, f.richese, action));
assert.equal(shipped.nexusRicheseCunningLast!.stage, 'shipped', difficulty);
assert.ok(typeof action.territory === 'string');
const cost = reserveShipmentCost({ faction: 'richese', halfRate: false }, territory(action.territory).type, 1);
assert.equal(player(shipped, f.richese).spice, player(f.game, f.richese).spice - cost);
assert.equal(shipped.nexusCards!.cards!.hands[f.richese], null);
custody(shipped);
}
});
void test('both original E2 natives actually deal and assign all14 custody under Basic/Advanced two-to-six-seat native starts', () => {
const additional = ['guild', 'atreides', 'emperor', 'harkonnen'] as const;
for (const advanced of [false, true]) for (const seats of [2, 3, 4, 5, 6]) {
const setup = initializePairedChoamNexusSkills({ advanced, factions: ['richese', 'choam', ...additional.slice(0, seats - 2)] });
const starts = pairedSkillsSnapshot(setup);
const game = completePairedChoamSkillsSetup(setup);
assert.equal(game.leaderSkills!.assignments.length, seats);
for (const p of starts.players) {
assert.deepEqual(player(game, p.id).hand, p.hand);
}
assert.equal(player(game, 'richese').noField!.deployed, null);
assert.equal(game.nexusCards!.cards!.deck.length, 12, 'Original Nexus cards are still physically undealt before the first real worm');
custody(game);
const firstStorm = advancePairedChoamSkillsToPhase(game, 1);
assert.equal(firstStorm.turn, 1);
assert.equal(firstStorm.phase, 1);
custody(firstStorm);
}
});
@@ -0,0 +1,313 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { territory } from '../game/board';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { validateLeaderSkills } from '../game/leader-skills';
import { nexusFaceDancersAction } from '../game/nexus-tleilaxu-options';
import {
advancePairedIxNexusSkills, beginPairedIxNexusSkillsCunning, choosePairedIxNexusSkillsSecondBattle,
createPairedIxNexusSkillsFixture, finishPairedIxNexusSkillsBattle, finishPairedIxNexusSkillsTurn,
pairedIxNexusSkillsPlayer as player, preparePairedIxNexusSkillsBattle, preparePairedIxNexusSkillsExpiryBattle,
quotePairedIxNexusSkillsBattle, revealPairedIxNexusSkillsBattle, stepPairedIxNexusSkills,
type PairedIxNexusSkillsFixture, type PairedIxNexusSkillsStep,
} from './fixture-paired-ix-nexus-skills';
function reject(game: Game, actor: string, action: Action): void {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before, 'Rejected consumer actions cannot consume physical cards, forces, spice or receipts');
}
function treachery(game: Game): string[] {
return [...game.deck, ...game.discard, ...(game.ixSetupCards ?? []), ...(game.ixAuction?.cards ?? []),
...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? []),
...game.players.flatMap(p => p.hand)].map(c => c.id).sort();
}
function custody(game: Game, original: Game): void {
assert.equal(treachery(game).length, 47);
assert.deepEqual(treachery(game), treachery(original));
assert.equal(new Set(treachery(game)).size, 47);
validateLeaderSkills(game.leaderSkills!, game.players);
const skills = [...game.leaderSkills!.deck, ...Object.values(game.leaderSkills!.offers).flatMap(o => o.cards),
...game.leaderSkills!.assignments.map(a => a.skill)].sort();
assert.deepEqual(skills, LEADER_SKILL_CARDS.map(c => c.id).sort());
const nexus = game.nexusCards!.cards!;
const faces = [...nexus.deck, ...nexus.discard, ...Object.values(nexus.hands).filter((c): c is NonNullable<typeof c> => c !== null)];
assert.equal(faces.length, 12); assert.equal(new Set(faces).size, 12);
const stock = (g: Game) => [...(g.traitorReserve ?? []), ...g.players.flatMap(p => [...p.traitors, ...(p.faceDancers ?? []).map(c => c.leader)])].sort();
assert.deepEqual(stock(game), stock(original)); assert.equal(new Set(stock(game)).size, stock(game).length);
for (const p of game.players) {
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum, n) => sum + n, 0), 20, p.faction);
if (p.elites) {
const old = player(original, p.id).elites!;
assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0),
old.reserves + old.tanks + Object.values(old.forces).reduce((sum, n) => sum + n, 0));
for (const [key, n] of Object.entries(p.elites.forces)) assert.ok(n <= (p.forces[key] ?? 0));
}
}
}
function city(game: Game, actor: string): number {
return game.phase === 7 && !game.phaseOpening ? quoteSpiceCollection(game).receipts
.filter(r => r.player === actor).reduce((sum, r) => sum + r.collected + r.strongholds, 0) : 0;
}
function money(revealed: Game, settled: Game): void {
const quote = quotePairedIxNexusSkillsBattle(revealed);
for (const p of revealed.players) {
const paid = quote.payments.find(r => r.player === p.id)?.ownPayment ?? 0;
const bounty = quote.bounty?.player === p.id ? quote.bounty.amount : 0;
assert.equal(player(settled, p.id).spice, p.spice - paid + bounty + city(settled, p.id), p.faction);
}
assert.deepEqual(quote.strongholdIncome, [], 'Printed city collection is separate; this profile has no Stronghold Cards');
}
function chooseCyborgLosses(state: Game, f: PairedIxNexusSkillsFixture): Game {
const losses = finishPairedIxNexusSkillsBattle(state, 'losses');
assert.equal(losses.decision?.kind, 'battleLosses');
if (losses.decision?.kind !== 'battleLosses') throw Error('Actual typed casualty choice required');
const choice = losses.decision.options.findIndex(o => o.normal === 0 && o.elite === 3); assert.ok(choice >= 0);
assert.equal(player(losses, f.ixians).tanks, player(state, f.ixians).tanks, 'Typed commitment precedes physical Suk settlement');
return stepPairedIxNexusSkills(losses, { actor: f.ixians, action: { type: 'decision', choice } });
}
function dance(f: PairedIxNexusSkillsFixture, pending: Game): Game {
assert.equal(pending.decision?.kind, 'faceDance');
const count = player(pending, f.ixians).forces[f.location]; assert.ok(count >= 2);
return stepPairedIxNexusSkills(pending, { actor: f.tleilaxu, action: { type: 'decision', reveal: true,
sources: { [f.replacementSource]: 2, reserves: count - 2 }, sector: territory(f.kind).sectors[0] } });
}
for (const advanced of [false, true]) for (const band of ['normal', 'skilled'] as const) {
void test(`original paired E1 ${advanced ? 'Advanced' : 'Basic'} ${band} Warmaster: full unsupported Suboids, real bonus and typed losses`, () => {
const f = createPairedIxNexusSkillsFixture({ advanced, band, skill: 'warmaster', dial: 3, support: 0 });
assert.equal(f.beforeFirstMentat.turn, 1); assert.equal(f.afterFirstMentat.turn, 2);
assert.equal(f.beforeAlliance.nexus, true);
assert.equal(player(f.afterNexusDraw, f.partners[0]).ally, f.partners[1]);
assert.equal(player(f.afterNexusDraw, f.ixians).ally, null);
assert.equal(player(f.afterNexusDraw, f.tleilaxu).ally, null);
assert.equal(f.afterNexusDraw.nexusCards!.cards!.hands[f.ixians], 'ixians');
assert.equal(f.afterNexusDraw.nexusCards!.cards!.hands[f.tleilaxu], 'tleilaxu');
custody(f.afterNexusDraw, f.afterSetup);
const ready = preparePairedIxNexusSkillsBattle(f), offer = viewGame(ready, f.ixians).nexusSuboids!.offer!;
reject(ready, f.tleilaxu, { type: 'nexusSuboids', event: offer.event });
const active = beginPairedIxNexusSkillsCunning(f, ready);
assert.deepEqual(player(active, f.ixians).forces, player(ready, f.ixians).forces);
assert.deepEqual(player(active, f.ixians).elites, player(ready, f.ixians).elites);
assert.equal(player(active, f.ixians).spice, player(ready, f.ixians).spice);
assert.equal(active.nexusCards!.cards!.hands[f.ixians], null);
reject(active, f.ixians, { type: 'nexusSuboids', event: offer.event });
const revealed = revealPairedIxNexusSkillsBattle(f, { state: active });
const quote = quotePairedIxNexusSkillsBattle(revealed);
assert.equal(quote.result, 'normal'); assert.equal(quote.winner, f.ixians);
assert.equal(quote.casualties!.forces.normalFixedHalf, false);
assert.equal(quote.casualties!.forces.normalFreeSupport, true);
const side = revealed.battle!.attacker === f.ixians ? 'attacker' : 'defender';
assert.deepEqual(quote.leaderSkillBonuses[side].applied, [{ skill: 'warmaster', amount: band === 'skilled' ? 3 : 1, mode: band }]);
assert.equal(quote.leaderStrengths[side], player(revealed, f.ixians).leaders.find(l => l.id === f.planActions[0].action.leader)!.strength);
assert.ok(quote.casualties!.options.some(o => o.normal === 3 && o.elite === 0));
if (advanced) {
const ordinary = quotePairedIxNexusSkillsBattle(revealPairedIxNexusSkillsBattle(f));
assert.ok(!ordinary.casualties!.options.some(o => o.normal === 3 && o.elite === 0));
assert.equal(quote.payments.find(r => r.player === f.ixians)!.ownPayment, 0);
assert.equal(quote.payments.find(r => r.player === f.ixians)!.bankSupport, 0);
} else assert.deepEqual(quote.payments, []);
const losses = finishPairedIxNexusSkillsBattle(revealed, 'losses');
if (losses.decision?.kind !== 'battleLosses') throw Error('Missing actual typed winner casualties');
const choice = losses.decision.options.findIndex(o => o.normal === 3 && o.elite === 0); assert.ok(choice >= 0);
const settled = finishPairedIxNexusSkillsBattle(stepPairedIxNexusSkills(losses, { actor: f.ixians, action: { type: 'decision', choice } }));
assert.equal(player(settled, f.ixians).forces[f.location], 3);
assert.equal(player(settled, f.ixians).elites!.forces[f.location], 3);
assert.equal(player(settled, f.ixians).tanks, player(revealed, f.ixians).tanks + 3);
assert.equal(player(settled, f.ixians).elites!.tanks, player(revealed, f.ixians).elites!.tanks);
assert.equal(player(settled, f.opponent).tanks, player(revealed, f.opponent).tanks + 6);
money(revealed, settled); custody(settled, f.afterSetup);
});
void test(`original paired E1 ${advanced ? 'Advanced' : 'Basic'} ${band} Suk: physical rescue precedes equal Suboid substitution, paid once`, () => {
const f = createPairedIxNexusSkillsFixture({ advanced, band, skill: 'suk-graduate' });
const revealed = revealPairedIxNexusSkillsBattle(f, { cunning: true }), quote = quotePairedIxNexusSkillsBattle(revealed);
assert.equal(quote.winner, f.ixians); assert.equal(quote.sukGraduate!.mode, band);
if (advanced) {
const payment = quote.payments.find(r => r.player === f.ixians)!;
assert.equal(payment.ownPayment, 1); assert.equal(payment.bankSupport, 0);
} else assert.deepEqual(quote.payments, []);
const original = player(revealed, f.ixians);
let selected = chooseCyborgLosses(revealed, f);
let event = '', choice = 0;
if (band === 'skilled') {
const rescue = finishPairedIxNexusSkillsBattle(selected, 'rescue');
if (rescue.decision?.kind !== 'sukRescue') throw Error('Missing original skilled Suk rescue');
assert.equal(player(rescue, f.ixians).tanks, original.tanks, 'No physical losses occur before the skilled rescue choice');
choice = rescue.decision.options.findIndex(o => o.normal === 0 && o.elite === 1 && o.kept?.kind === 'elite' && o.kept.key === f.location);
assert.ok(choice >= 0); event = rescue.decision.event;
selected = stepPairedIxNexusSkills(rescue, { actor: f.ixians, action: { type: 'decision', event, choice } });
} else {
assert.equal(selected.lastBattleContext!.sukRescue!.completed, true, 'The sole normal elite rescue is applied automatically');
}
const pending = finishPairedIxNexusSkillsBattle(selected, 'substitution');
assert.equal(pending.decision?.kind, 'ixSubstitution');
const p = player(pending, f.ixians);
assert.equal(p.tanks, original.tanks + 2); assert.equal(p.elites!.tanks, original.elites!.tanks + 2);
assert.equal(p.battleLosses, original.battleLosses + 2);
assert.equal(p.reserves, original.reserves + (band === 'normal' ? 1 : 0));
assert.equal(p.elites!.reserves, original.elites!.reserves + (band === 'normal' ? 1 : 0));
assert.equal(p.forces[f.location], band === 'normal' ? 3 : 4);
assert.equal(p.elites!.forces[f.location] ?? 0, band === 'normal' ? 0 : 1);
assert.equal(p.spice, original.spice - (advanced ? 1 : 0));
reject(pending, f.ixians, { type: 'decision', event, choice });
reject(pending, f.ixians, { type: 'decision', sources: { [f.location]: 2 }, recover: { [f.location]: 3 } });
const exchanged = stepPairedIxNexusSkills(pending, { actor: f.ixians, action: { type: 'decision', sources: { [f.location]: 2 }, recover: { [f.location]: 2 } } });
const settled = finishPairedIxNexusSkillsBattle(exchanged), after = player(settled, f.ixians);
assert.equal(after.forces[f.location], p.forces[f.location]);
assert.equal(after.elites!.forces[f.location], band === 'normal' ? 2 : 3);
assert.equal(after.tanks, p.tanks, 'Equal physical exchange does not count the original Cyborg loss twice');
assert.equal(after.elites!.tanks, original.elites!.tanks);
assert.equal(after.battleLosses, p.battleLosses);
assert.equal(after.reserves, p.reserves); assert.equal(after.elites!.reserves, p.elites!.reserves);
assert.equal(settled.lastBattleContext!.sukRescue!.completed, true);
assert.equal(settled.lastBattleContext!.ixSubstitution!.completed, true);
money(revealed, settled); custody(settled, f.afterSetup);
});
}
void test('own sealed plan blocks Cunning; active Suboids persist across real battles and expire at the actual next turn', () => {
const f = createPairedIxNexusSkillsFixture({ skill: 'warmaster', dial: 3, support: 0, secondBattle: true });
const prepared = preparePairedIxNexusSkillsBattle(f), offer = viewGame(prepared, f.ixians).nexusSuboids!.offer!;
const sealed = stepPairedIxNexusSkills(prepared, f.planActions[0]);
reject(sealed, f.ixians, { type: 'nexusSuboids', event: offer.event });
const settled = finishPairedIxNexusSkillsBattle(revealPairedIxNexusSkillsBattle(f, { cunning: true }));
const next = choosePairedIxNexusSkillsSecondBattle(f, settled);
assert.equal(viewGame(next, f.ixians).nexusSuboids!.active, true);
const unused = (actor: string) => player(next, actor).leaders.find(l => !l.dead && !l.usedAt &&
!next.leaderSkills!.assignments.some(assignment => assignment.leader === l.id))!;
const plans: PairedIxNexusSkillsStep[] = [
{ actor: f.ixians, action: { type: 'battlePlan', leader: unused(f.ixians).id, dial: 3, support: 0, weapon: null, defense: null } },
{ actor: f.opponent, action: { type: 'battlePlan', leader: unused(f.opponent).id, dial: 0, support: 0, weapon: null, defense: null } },
];
const second = revealPairedIxNexusSkillsBattle(f, { state: next, plans });
assert.equal(quotePairedIxNexusSkillsBattle(second).casualties!.forces.normalFreeSupport, true);
const secondSettled = finishPairedIxNexusSkillsBattle(second), turn = finishPairedIxNexusSkillsTurn(secondSettled);
assert.equal(viewGame(turn, f.ixians).nexusSuboids!.active, false);
const expired = preparePairedIxNexusSkillsExpiryBattle(f, turn);
const leader = player(expired, f.ixians).leaders.find(l => !l.dead &&
!expired.leaderSkills!.assignments.some(assignment => assignment.leader === l.id))!;
reject(expired, f.ixians, { type: 'battlePlan', leader: leader.id, dial: 3, support: 0, weapon: null, defense: null });
const legal = stepPairedIxNexusSkills(expired, { actor: f.ixians, action: { type: 'battlePlan', leader: leader.id, dial: 1.5, support: 0, weapon: null, defense: null } });
assert.equal(legal.battle!.plans[f.ixians].dial, 1.5); custody(legal, f.afterSetup);
});
for (const advanced of [false, true]) void test(`original paired ${advanced ? 'Advanced' : 'Basic'} Face Dance: Suk/card cleanup/rewards finish first; partial Cunning draws before retirement`, () => {
const f = createPairedIxNexusSkillsFixture({ advanced, program: 'faceDance', skill: 'suk-graduate' });
const revealed = revealPairedIxNexusSkillsBattle(f, { cunning: true }), quote = quotePairedIxNexusSkillsBattle(revealed);
const enemy = player(revealed, f.opponent).leaders.find(l => l.id === f.planActions[1].action.leader)!;
assert.deepEqual(quote.bounty, { player: f.ixians, amount: enemy.strength });
const cards = finishPairedIxNexusSkillsBattle(revealed, 'cards');
assert.equal(cards.decision?.kind, 'battleCards');
assert.equal(cards.lastBattleContext!.sukRescue!.completed, true);
const defense = f.planActions[0].action.defense as string;
const cleaned = stepPairedIxNexusSkills(cards, { actor: f.ixians, action: { type: 'decision', discard: [defense] } });
const pending = finishPairedIxNexusSkillsBattle(cleaned, 'faceDance');
assert.ok(pending.discard.some(c => c.id === defense));
assert.equal(player(pending, f.opponent).leaders.find(l => l.id === enemy.id)!.dead, true);
money(revealed, pending);
const winner = structuredClone(player(pending, f.ixians));
const oldStock = structuredClone(player(pending, f.tleilaxu).faceDancers!);
assert.ok(oldStock.some(c => c.leader === f.trainer && !c.revealed));
reject(pending, f.tleilaxu, { type: 'decision', reveal: true, sources: { reserves: winner.forces[f.location] + 1 }, sector: territory(f.kind).sectors[0] });
const after = dance(f, pending), returned = player(after, f.ixians), replacement = player(after, f.tleilaxu);
assert.equal(returned.forces[f.location] ?? 0, 0); assert.equal(returned.elites!.forces[f.location] ?? 0, 0);
assert.equal(returned.reserves, winner.reserves + winner.forces[f.location]);
assert.equal(returned.elites!.reserves, winner.elites!.reserves + (winner.elites!.forces[f.location] ?? 0));
assert.equal(returned.tanks, winner.tanks); assert.equal(returned.battleLosses, winner.battleLosses);
assert.equal(returned.elites!.tanks, winner.elites!.tanks);
assert.equal(returned.leaders.find(l => l.id === f.trainer)!.deaths, winner.leaders.find(l => l.id === f.trainer)!.deaths + 1);
assert.equal(returned.leaders.find(l => l.id === f.trainer)!.dead, true);
assert.equal(after.leaderSkills!.assignments.some(a => a.leader === f.trainer), false);
assert.equal(after.leaderSkills!.deck.filter(s => s === f.skill).length, 1);
assert.deepEqual(returned.hand, winner.hand);
for (const p of pending.players) assert.equal(player(after, p.id).spice, p.spice + city(after, p.id), 'Face Dance grants no extra bounty/support charge');
assert.equal(replacement.forces[f.location], winner.forces[f.location]);
assert.equal(replacement.forces[f.replacementSource], 1);
assert.equal(replacement.reserves, player(pending, f.tleilaxu).reserves - winner.forces[f.location] + 2);
const revealedStock = replacement.faceDancers!.filter(c => c.revealed); assert.equal(revealedStock.length, 1);
const untouched = replacement.faceDancers!.filter(c => !c.revealed);
assert.deepEqual(untouched, oldStock.filter(c => c.leader !== f.trainer));
const view = viewGame(after, f.tleilaxu), offer = view.nexusTleilaxu!.cunning!;
const action = nexusFaceDancersAction(view, offer.event); assert.ok(action);
const reserve = [...after.traitorReserve!], allowance = replacement.faceDancerReplacedTurn;
reject(after, f.tleilaxu, { ...action, leaders: [f.trainer] });
const replaced = stepPairedIxNexusSkills(after, { actor: f.tleilaxu, action });
assert.deepEqual(player(replaced, f.tleilaxu).faceDancers!.filter(c => untouched.some(old => old.leader === c.leader)), untouched);
assert.deepEqual(player(replaced, f.tleilaxu).faceDancers!.filter(c => !untouched.some(old => old.leader === c.leader)), [{ leader: reserve[0], revealed: false }]);
assert.notEqual(reserve[0], f.trainer);
assert.deepEqual([...replaced.traitorReserve!].sort(), [...reserve.slice(1), f.trainer].sort());
assert.equal(player(replaced, f.tleilaxu).faceDancerReplacedTurn, allowance);
assert.equal(replaced.nexusCards!.cards!.hands[f.tleilaxu], null);
reject(replaced, f.tleilaxu, action); custody(replaced, f.afterSetup);
const mentat = advancePairedIxNexusSkills(replaced, g => g.phase === 8 && !g.phaseOpening && !g.response && !g.decision);
const ordinary = stepPairedIxNexusSkills(mentat, { actor: f.tleilaxu, action: { type: 'replaceFaceDancer', leader: untouched[0].leader } });
const done = advancePairedIxNexusSkills(ordinary, g => !g.response && !g.decision);
assert.equal(player(done, f.tleilaxu).faceDancerReplacedTurn, done.turn);
reject(done, f.tleilaxu, { type: 'replaceFaceDancer', leader: player(done, f.tleilaxu).faceDancers![0].leader });
const turn = finishPairedIxNexusSkillsTurn(done);
assert.equal(turn.leaderSkills!.deck.filter(s => s === f.skill).length, 1); custody(turn, f.afterSetup);
if (advanced) {
const revival = advancePairedIxNexusSkills(turn, g => g.phase === 4 && !g.phaseOpening && !g.response && !g.decision);
reject(revival, f.tleilaxu, { type: 'reviveForeignGhola', leader: enemy.id });
assert.equal(player(revival, f.opponent).leaders.find(l => l.id === enemy.id)!.gholaBy, undefined,
'An actual dead foreign disc does not bypass the native five-living-leader ghola limit');
}
});
void test('normal-band Face Dance selects a genuine matching untrained disc and cannot return its living trainer skill', () => {
const f = createPairedIxNexusSkillsFixture({ program: 'faceDance', band: 'normal', skill: 'warmaster' });
const selected = f.planActions[0].action.leader as string;
assert.notEqual(selected, f.trainer);
const original = structuredClone(player(f.afterSetup, f.tleilaxu).faceDancers!);
assert.ok(original.some(c => c.leader === selected && !c.revealed));
const revealed = revealPairedIxNexusSkillsBattle(f, { cunning: true });
const quote = quotePairedIxNexusSkillsBattle(revealed);
const side = revealed.battle!.attacker === f.ixians ? 'attacker' : 'defender';
assert.deepEqual(quote.leaderSkillBonuses[side].applied, [{ skill: 'warmaster', amount: 1, mode: 'normal' }]);
const pending = finishPairedIxNexusSkillsBattle(revealed, 'faceDance');
assert.deepEqual(player(pending, f.tleilaxu).faceDancers, original);
const after = dance(f, pending);
assert.equal(player(after, f.ixians).leaders.find(l => l.id === selected)!.dead, true);
assert.equal(player(after, f.ixians).leaders.find(l => l.id === f.trainer)!.dead, false);
assert.deepEqual(after.leaderSkills!.assignments.find(a => a.owner === f.ixians),
pending.leaderSkills!.assignments.find(a => a.owner === f.ixians));
assert.equal(after.leaderSkills!.deck.includes('warmaster'), false);
custody(after, f.afterSetup);
});
void test('native bot Face Dance uses original physical reserve/board sources after genuine skilled winner aftermath', () => {
const f = createPairedIxNexusSkillsFixture({ program: 'faceDance', skill: 'suk-graduate' });
const pending = finishPairedIxNexusSkillsBattle(revealPairedIxNexusSkillsBattle(f, { cunning: true }), 'faceDance');
const view = viewGame(pending, f.tleilaxu); view.players.find(p => p.id === f.tleilaxu)!.bot = 'Easy';
const action = botActions(view).find(a => a.type === 'decision'); assert.ok(action); assert.equal(action.reveal, true);
const after = stepPairedIxNexusSkills(pending, { actor: f.tleilaxu, action });
assert.equal(player(after, f.ixians).forces[f.location] ?? 0, 0);
assert.equal(player(after, f.tleilaxu).forces[f.location], player(pending, f.ixians).forces[f.location]);
assert.equal(after.leaderSkills!.assignments.some(a => a.leader === f.trainer), false);
custody(after, f.afterSetup);
});
void test('authenticated original starting hands/offers continue without redeal; foreign leader assignment and absent requested skill are rejected', () => {
const lobby = createGame('PAIREDIXSKILLSAUTH', newPlayer('http-ix', 'Ix', 'ixians'), true, ['ix']);
for (const [id, faction] of [['http-tl', 'tleilaxu'], ['http-guild', 'guild'], ['http-emperor', 'emperor']] as const)
joinGame(lobby, newPlayer(id, faction, faction));
const fresh = createPairedIxNexusSkillsFixture({ initial: lobby, skill: 'warmaster' });
const saved = structuredClone(fresh.offered), owner = player(saved, fresh.ixians);
assert.ok(owner.hand.length > 0); assert.ok(Object.keys(saved.leaderSkills!.offers).length > 0);
const offer = saved.leaderSkills!.offers[fresh.ixians], foreign = player(saved, fresh.opponent).leaders[0];
reject(saved, fresh.ixians, { type: 'leaderSkill', event: offer.event, skill: 'warmaster', leader: foreign.id });
const unavailable = LEADER_SKILL_CARDS.find(c => !offer.cards.includes(c.id))!.id;
assert.throws(() => createPairedIxNexusSkillsFixture({ initial: saved, skill: unavailable }));
const continued = createPairedIxNexusSkillsFixture({ initial: saved, skill: 'warmaster' });
assert.deepEqual(continued.initial, saved);
assert.deepEqual(continued.afterSetup.players.map(p => [p.id, p.hand]), saved.players.map(p => [p.id, p.hand]));
assert.deepEqual(continued.offered.leaderSkills!.offers, saved.leaderSkills!.offers);
assert.equal(continued.ixians, 'http-ix'); assert.equal(continued.tleilaxu, 'http-tl');
assert.equal(continued.afterNexusDraw.nexusCards!.cards!.hands['http-ix'], 'ixians');
assert.equal(continued.afterNexusDraw.nexusCards!.cards!.hands['http-tl'], 'tleilaxu');
custody(continued.afterNexusDraw, continued.afterSetup);
});
+12 -7
View File
@@ -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' | 'nexus-skills';
| 'paired-nexus-tech' | 'paired-nexus-stronghold' | 'paired-nexus-stronghold-tech' | 'nexus-skills' | 'paired-nexus-skills';
type Rules = 'basic' | 'advanced';
type Scenario = {
@@ -223,6 +223,9 @@ const PAIRED_NEXUS_SCENARIOS: readonly Scenario[] = EXPANSION_ROSTER_SCENARIOS
profile: scenario.profile.replace('-roster', '-nexus') as Profile,
ordinal: scenario.ordinal + 30,
}));
const PAIRED_NEXUS_SKILLS_SCENARIOS: readonly Scenario[] = PAIRED_NEXUS_SCENARIOS
.filter(scenario => scenario.profile !== 'ecaz-nexus')
.map((scenario, index): Scenario => ({ ...scenario, profile: 'paired-nexus-skills', ordinal: 1450 + index }));
const PAIRED_NEXUS_MODULE_SCENARIOS: readonly Scenario[] =
(['paired-nexus-tech', 'paired-nexus-stronghold', 'paired-nexus-stronghold-tech'] as const)
.flatMap((profile, band) => PAIRED_NEXUS_SCENARIOS
@@ -415,7 +418,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 === '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 skillProfile = (profile: string) => profile === 'nexus-skills' || profile === 'paired-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));
@@ -456,7 +459,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-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] ' +
'[--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|paired-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.' +
@@ -471,6 +474,7 @@ function usage() {
+ '\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.'
+ '\nPaired-nexus-skills adds all14 Skills to both original Ixians+Tleilaxu OR CHOAM+Richese, classic opponents and one family deck/all12 Nexus, Basic/Advanced2..6. Native setup, actual closing draws, typed skills/Cunning, Face Dancers and one-invoice physical marker pairs reuse original rules. Tech/Strongholds/HW, mixed families, E3, Banker/Mentat previews, borrowed Smuggler/pair companions, public starts and pending rulings remain excluded.'
);
}
@@ -525,7 +529,7 @@ function scenarioName(scenario: Scenario) {
const suffix = native.length > 1 ? '-' + native.join('-') : scenario.expansions.includes('ix') ? '-ix-deck' : '';
return `${scenario.profile}${suffix}-${scenario.roster.length}-${scenario.rules}`;
}
if (pairedNexusModuleProfile(scenario.profile)) {
if (pairedNexusModuleProfile(scenario.profile) || scenario.profile === 'paired-nexus-skills') {
const family = scenario.expansions[0] === 'ix' ? 'ixians-tleilaxu' : 'choam-richese';
return `${scenario.profile}-${family}-${scenario.roster.length}-${scenario.rules}`;
}
@@ -587,7 +591,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-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')
if (scenario.profile === 'paired-nexus-skills' || 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');
@@ -654,10 +658,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, ...NEXUS_SKILLS_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, ...PAIRED_NEXUS_SKILLS_SCENARIOS].find(
(candidate) =>
(candidate.profile === 'homeworld' || candidate.profile === 'homeworld-nexus' || candidate.profile === 'combined-homeworld-nexus') === !!game.homeworlds &&
(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 &&
(candidate.profile === 'paired-nexus-skills' || 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 &&
@@ -885,6 +889,7 @@ async function main() {
? MODULE_SCENARIOS
: pairedNexusModuleProfile(profile) ? PAIRED_NEXUS_MODULE_SCENARIOS
: profile === 'nexus-skills' ? NEXUS_SKILLS_SCENARIOS
: profile === 'paired-nexus-skills' ? PAIRED_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
+1 -1
View File
@@ -33,7 +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.',
'\nLeader-skills --nexus-cards explicitly adds all12 Nexus to fresh classic/base33 OR paired Ixians+Tleilaxu/Ix47 OR paired CHOAM+Richese/CHOAM35+cache setups with classics and all14 skills, two-through-six seats Basic/Advanced. Original starting hands/offers, skill/Traitor/native order and genuine closing deals remain. Mixed families, E3, Tech/Strongholds/HW, Banker/Mentat previews, borrowed Smuggler/pair-companion arithmetic, public starts and unresolved interactions stay separate.',
);
return;
}