Compose paired Nexus Skills with original Tech and Strongholds

This commit is contained in:
Quintine committed 2026-10-05 01:08:58 +11:00
1 parent 449e27b415
commit 2d24f43721
14 files changed
+1709 -21

No files matched your search

+14 -2
View File
@@ -102,7 +102,18 @@ export function nextNativeSkillsStrongholdStep(game: Game): StrongholdFactionsNa
}
function act(game: Game, next: StrongholdFactionsNativeStep, actions?: StrongholdFactionsNativeStep[]): Game {
actions?.push(structuredClone(next));
return applyAction(game, next.actor, next.action);
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const native = crypto.getRandomValues.bind(crypto);
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array) array.fill(0xffffffff);
else Reflect.apply(native, crypto, [array]);
return array;
};
try { return applyAction(game, next.actor, next.action); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
export function advanceNativeSkillsStronghold(state: Game, until: (g: Game) => boolean,
actions?: StrongholdFactionsNativeStep[], firstDial = 0): Game {
@@ -197,7 +208,8 @@ export function createNativeSkillsStrongholdFixture(options: NativeSkillsStrongh
if (!game.strongholdCards) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: true });
if (options.optionalTech && !game.techTokens) game = applyAction(game, game.host, { type: 'techTokens', enabled: true });
if (game.techTokens) assert.ok(game.players.length >= 3);
const initial = structuredClone(game), actions: StrongholdFactionsNativeStep[] = [], staging: string[] = [];
const initial = structuredClone(game), actions: StrongholdFactionsNativeStep[] = [],
staging: string[] = ['Scoped original scalar action entropy keeps native source programs reproducible; UUID byte arrays remain native.'];
const native: AdvancedNativeSkillsOptions = { family, skillOwner: ownerFaction, requestedSkill: 'suk-graduate', initial: game };
game = initializeAdvancedNativeSkillsSetup(native);
const offered = structuredClone(game);
@@ -0,0 +1,239 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
import { territory } from '../game/board';
import { strongholdControllers } from '../game/stronghold-cards';
import {
advancePairedChoamSkills, advancePairedChoamSkillsToPhase, allowPairedChoamSkills,
assertPairedChoamSkillsCustody, completePairedChoamSkillsSetup, initializePairedChoamNexusSkills,
nextPairedChoamSkillsStep, pairedSkillsClean, pairedSkillsPlayer, pairedSkillsSnapshot,
placePairedSkillsForce, revealPairedSkillsRichese, stepPairedChoamSkills,
type PairedChoamNexusSkillsFixture, type PairedChoamNexusSkillsOptions, type PairedChoamNexusSkillsStep,
} from './fixture-paired-choam-nexus-skills';
/** Source composition, not a new ruling:
* docs/NEXUS_CARD_RULES.md, "Paired E1/E2 Nexus and Leader Skills composition"
* (signed Richese pair and original CHOAM Special fuel),
* docs/LEADER_SKILLS_RUNTIME.md, "Paired E1/E2 Nexus composition" and
* "Advanced Stronghold composition"; original module custody/timing in
* fixture-paired-choam-nexus-modules.ts and fixture-native-skills-stronghold.ts.
* No borrowed Smuggler, pair companion, mixed No-Field plan or preview is added.
*/
export type PairedChoamNexusSkillsModulesOptions = PairedChoamNexusSkillsOptions & {
tech?: boolean;
strongholds?: boolean;
/** Conserved pre-END-Mentat occupant; the engine, never this fixture, earns custody. */
arrakeenOwner?: 'richese' | 'choam' | 'guild';
};
export type PairedChoamNexusSkillsModulesFixture = PairedChoamNexusSkillsFixture & {
tech: boolean;
strongholds: boolean;
};
export type PairedChoamNexusSkillsModulesStep = PairedChoamNexusSkillsStep;
// Keep the original legal native policies and human plan/posture boundaries.
export {
nextPairedChoamSkillsStep as nextPairedChoamSkillsModulesStep,
stepPairedChoamSkills as stepPairedChoamSkillsModules,
advancePairedChoamSkills as advancePairedChoamSkillsModules,
advancePairedChoamSkillsToPhase as advancePairedChoamSkillsModulesToPhase,
completePairedChoamSkillsSetup as completePairedChoamSkillsModulesSetup,
allowPairedChoamSkills as allowPairedChoamSkillsModules,
revealPairedSkillsRichese as revealPairedModulesRichese,
openPairedSkillsBattle as openPairedModulesSkillsBattle,
pairedSkillsBattlePlans as pairedModulesSkillsBattlePlans,
finishPairedSkillsBattle as finishPairedModulesSkillsBattle,
quotePairedSkillsBattle as quotePairedModulesSkillsBattle,
pairedSkillsRicheseCunningRequest as pairedModulesSkillsRicheseCunningRequest,
pairedSkillsChoamCunningRequest as pairedModulesSkillsChoamCunningRequest,
stagePairedSkillsPlanetologistRoute as stagePairedModulesSkillsPlanetologistRoute,
holdPairedSkillsCard as holdPairedModulesSkillsCard,
placePairedSkillsForce as placePairedModulesSkillsForce,
pairedSkillsClean as pairedModulesSkillsClean,
pairedSkillsPlayer as pairedModulesSkillsPlayer,
pairedSkillsSnapshot as pairedModulesSkillsSnapshot,
assertPairedChoamSkillsCustody as assertPairedChoamSkillsModulesCustody,
} from './fixture-paired-choam-nexus-skills';
/** Lobby options use real module actions before the original all14 initializer.
* Supplied setup offers, first-dealt hands and authenticated IDs are authoritative.
* An existing module selection is retained unless an explicit lobby option changes it.
*/
export function initializePairedChoamNexusSkillsModules(options: PairedChoamNexusSkillsModulesOptions = {}): Game {
const advanced = options.advanced ?? options.initial?.advanced ?? true;
const factions = options.factions ?? ['richese', 'choam', 'guild', 'atreides'] as const;
let game = options.initial ? pairedSkillsSnapshot(options.initial)
: createGame('PAIREDCHOAMNEXUSSKILLSMODULES', newPlayer(options.seatIds?.[0] ?? factions[0], factions[0], factions[0]), advanced, ['choam']);
if (!options.initial) for (const faction of factions.slice(1))
joinGame(game, newPlayer(options.seatIds?.[game.players.length] ?? faction, faction, faction));
const tech = options.tech ?? (options.initial ? !!game.techTokens : true);
const strongholds = options.strongholds ?? (options.initial ? !!game.strongholdCards : advanced);
assert.ok(tech || strongholds, 'Select original Tech and/or Stronghold Cards');
assert.equal(game.advanced, advanced);
assert.ok(!strongholds || advanced, 'Original Stronghold Cards require Advanced');
assert.ok(game.players.length >= (tech ? 3 : 2) && game.players.length <= 6);
if (game.status === 'lobby') {
if (!!game.techTokens !== tech) game = applyAction(game, game.host, { type: 'techTokens', enabled: tech });
if (!!game.strongholdCards !== strongholds) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: strongholds });
} else {
assert.equal(!!game.techTokens, tech, 'A saved setup cannot change its original Tech selection');
assert.equal(!!game.strongholdCards, strongholds, 'A saved setup cannot change its original Stronghold selection');
}
return initializePairedChoamNexusSkills({ ...options, initial: game, advanced });
}
/** Labeled rule-position control: every original counter stays in native custody. */
function reserveBoard(game: Game, staging: string[]): void {
for (const p of game.players) {
p.reserves += Object.values(p.forces).reduce((sum, amount) => sum + amount, 0);
p.forces = {};
if (p.elites) {
p.elites.reserves += Object.values(p.elites.forces).reduce((sum, amount) => sum + amount, 0);
p.elites.forces = {};
}
if (p.advisors) p.advisors = {};
}
staging.push('Conserved original board counters into their own reserves; no wallet, phase, turn, module owner, skill or earned receipt is assigned.');
}
function orderSpice(game: Game, kinds: ('worm' | 'land')[], staging: string[]): void {
const front = kinds.map(kind => {
const index = game.spiceDeck.findIndex(card => kind === 'worm' ? 'worm' in card : 'territory' in card);
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 discards are unchanged.`);
}
/** Actual native starts/skill-first setup, first Storm, previous zero-marker,
* END Mentat, worm, reciprocal classic alliance and closing Cunning draws.
* Four seats are necessary for BOTH unallied draws; a two-seat SH game can use
* initialize/complete/step/advance exports but never receives fabricated Nexus.
*/
export function createPairedChoamNexusSkillsModulesFixture(options: PairedChoamNexusSkillsModulesOptions = {}): PairedChoamNexusSkillsModulesFixture {
const actions: PairedChoamNexusSkillsStep[] = [], staging: string[] = [];
let game = initializePairedChoamNexusSkillsModules(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 draws 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);
orderSpice(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);
const actors: Record<'richese' | 'choam' | 'guild', string> = { richese, choam, guild };
placePairedSkillsForce(game, actors[options.arrakeenOwner ?? 'choam'], `arrakeen:${territory('arrakeen').sectors[0]}`, 1, staging);
let beforeFirstMentat: Game | undefined, firstMentatStep: PairedChoamNexusSkillsStep | undefined;
for (let count = 0; game.turn === 1 && count < 200; count++) {
const previous = pairedSkillsSnapshot(game), next = 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);
orderSpice(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 unused original ${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, tech: !!game.techTokens, strongholds: !!game.strongholdCards };
}
/** Preserve the original response boundary before the actual Guild allowance. */
export function advancePairedModulesRicheseToGuild(game: Game, actions?: PairedChoamNexusSkillsStep[]): Game {
return advancePairedChoamSkills(game, g => g.decision?.kind === 'guildShipment' || pairedSkillsClean(g), actions);
}
export function pairedModulesRicheseGuildStep(game: Game, allow = true): PairedChoamNexusSkillsStep {
const decision = game.decision;
assert.ok(decision?.kind === 'guildShipment', 'Use the actual native Guild interception decision');
return { actor: decision.player, action: { type: 'decision', allow } };
}
export function advancePairedModulesBattleToDecision(game: Game, kind: 'sukRescue' | 'battleCards' | 'techToken', actions?: PairedChoamNexusSkillsStep[]): Game {
return advancePairedChoamSkills(game, g => g.decision?.kind === kind || !g.battle && pairedSkillsClean(g), actions);
}
export function finishPairedChoamSkillsModulesMovement(game: Game, actions?: PairedChoamNexusSkillsStep[]): Game {
return advancePairedChoamSkillsToPhase(game, 6, actions);
}
/** Current physical control is not held-card custody; only real END Mentat updates owners. */
export function pairedModulesSkillsStrongholdControls(game: Game) {
return strongholdControllers(game.players, false);
}
/** Native human declaration stays an Action, not a fixture-invented proof or frame. */
export function declarePairedModulesSkills(game: Game, actor: string, action: Action, actions?: PairedChoamNexusSkillsStep[]): Game {
return stepPairedChoamSkills(game, { actor, action }, actions);
}
/** Stop before a real human visibility choice; do not silently choose its band. */
export function openPairedModulesSkillsBattlePosture(game: Game, owner: string, opponent: string,
options: { territory?: string; actions?: PairedChoamNexusSkillsStep[] } = {}): Game {
game = advancePairedChoamSkillsToPhase(game, 6, options.actions);
assert.equal(game.phase, 6, 'The real ordinary battle requires conserved opposing 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 } }, options.actions);
return advancePairedChoamSkills(game, g => g.decision?.kind === 'leaderSkillVisibility' ||
pairedSkillsClean(g) && !!g.battle && (!g.battle.preLeader || g.battle.preLeader.closed) && !g.battle.preparation, options.actions);
}
/** Continue actual preparation after the caller's human posture action. */
export function advancePairedModulesSkillsToPlans(game: Game, actions?: PairedChoamNexusSkillsStep[]): Game {
assert.ok(game.battle && !game.battle.revealed, 'Continue the current ordinary unsealed battle');
return advancePairedChoamSkills(game, g => pairedSkillsClean(g) && !!g.battle &&
(!g.battle.preLeader || g.battle.preLeader.closed) && !g.battle.preparation, actions);
}
/** The original two plans reveal before traitor calls, physical losses or rewards. */
export function revealPairedModulesSkillsBattle(game: Game, plans: readonly PairedChoamNexusSkillsStep[],
actions?: PairedChoamNexusSkillsStep[]): Game {
for (const plan of plans) {
assert.equal(plan.action.type, 'battlePlan');
game = stepPairedChoamSkills(game, plan, actions);
game = allowPairedChoamSkills(game, actions);
}
assert.ok(game.battle?.revealed, 'Both original plans must actually reveal');
return game;
}
@@ -0,0 +1,459 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, handLimit, initializeLeaderSkillsGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game, type Player,
} from '../game/engine';
import { MOBILE_LOCATION, MOBILE_STRONGHOLD, 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 { ownedTech } from '../game/tech-tokens';
import { strongholdControllers, type StrongholdId } from '../game/stronghold-cards';
import { traitorDeck } from '../game/traitors';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
export type PairedIxNexusSkillsModulesStep = { actor: string; action: Action };
export type PairedIxNexusSkillsModulesOptions = {
/** 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;
tech?: boolean;
strongholds?: boolean;
mobileBattle?: boolean;
program?: 'suboids' | 'faceDance';
skill?: LeaderSkillId;
band?: 'normal' | 'skilled';
dial?: number;
support?: number;
};
export type PairedIxNexusSkillsModulesSourceFacts = {
sources: readonly string[];
staging: string[];
turn: number;
phase: number;
cardOwners: Record<StrongholdId, string | null> | null;
controllers: Record<StrongholdId, string | null>;
tech: Game['techTokens'] | null;
stock: { player: string; faceDancers: NonNullable<Player['faceDancers']> }[];
physicalForces: { player: string; reserves: number; tanks: number; forces: Record<string, number>; elites: Player['elites'] }[];
};
export type PairedIxNexusSkillsModulesFixture = {
initial: Game;
offered: Game;
afterSetup: Game;
afterFirstStorm: Game;
beforeFirstMentat: Game;
firstMentatStep: PairedIxNexusSkillsModulesStep;
afterFirstMentat: Game;
beforeAlliance: Game;
allianceActions: PairedIxNexusSkillsModulesStep[];
beforeNexusDraw: Game;
drawActions: PairedIxNexusSkillsModulesStep[];
afterNexusDraw: Game;
beforeBattle: Game;
battleStep: PairedIxNexusSkillsModulesStep;
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' | typeof MOBILE_STRONGHOLD;
sourceFacts: PairedIxNexusSkillsModulesSourceFacts;
location: string;
replacementSource: string;
planActions: PairedIxNexusSkillsModulesStep[];
actions: PairedIxNexusSkillsModulesStep[];
staging: string[];
};
export function pairedIxNexusSkillsModulesPlayer(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 nextPairedIxNexusSkillsModulesStep(game: Game): PairedIxNexusSkillsModulesStep | 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 === 'techToken') return { actor: d.player, action: { type: 'decision', token: d.choices[0] } };
if (d?.kind === 'strongholdCopy') return null;
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 stepPairedIxNexusSkillsModules(game: Game, step = nextPairedIxNexusSkillsModulesStep(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 advancePairedIxNexusSkillsModules(state: Game, until: (game: Game) => boolean,
actions: PairedIxNexusSkillsModulesStep[] = []): Game {
let game = structuredClone(state);
for (let i = 0; i < 2400; i++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const step = nextPairedIxNexusSkillsModulesStep(game); assert.ok(step, 'Native explicit human choice before requested boundary');
actions.push(structuredClone(step)); game = stepPairedIxNexusSkillsModules(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 = pairedIxNexusSkillsModulesPlayer(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 = pairedIxNexusSkillsModulesPlayer(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`);
}
/** Printed composition sources: docs/NEXUS_CARD_RULES.md, paired E1/E2
* Skills and Tech/Stronghold sections; docs/LEADER_SKILLS_RUNTIME.md, paired
* Nexus, Advanced Stronghold and native Tech sections. This adds no revival ruling. */
export const PAIRED_IX_NEXUS_SKILLS_MODULES_SOURCES = [
'docs/NEXUS_CARD_RULES.md#paired-e1e2-nexus-and-leader-skills-composition--4-october-2026',
'docs/NEXUS_CARD_RULES.md#paired-e1e2-tech-and-stronghold-composition--4-october-2026',
'docs/LEADER_SKILLS_RUNTIME.md#advanced-stronghold-composition--4-october-2026',
'docs/LEADER_SKILLS_RUNTIME.md#native-e1e2-tech-tokens-composition--4-october-2026',
] as const;
/** Two-seat Stronghold-only native setup is legal, but cannot manufacture an
* unallied closing draw. The battle program below separately requires classics. */
export function initializePairedIxNexusSkillsModulesSetup(options: PairedIxNexusSkillsModulesOptions = {}) {
const advanced = options.advanced ?? options.initial?.advanced ?? true;
const tech = options.tech ?? (options.initial ? !!options.initial.techTokens : true);
const strongholds = options.strongholds ?? (options.initial ? !!options.initial.strongholdCards : advanced);
assert.ok(tech || strongholds); assert.ok(!strongholds || advanced);
let game = options.initial ? structuredClone(options.initial)
: createGame('PAIREDIXNEXUSSKILLSMODULES', 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.homeworlds);
assert.ok(game.players.length >= (tech ? 3 : 2) && game.players.length <= 6);
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);
assert.ok(classics.every(p => ['atreides', 'harkonnen', 'emperor', 'guild', 'fremen', 'beneGesserit'].includes(p.faction)));
const actions: PairedIxNexusSkillsModulesStep[] = [], 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');
for (const [type, enabled, selectedModule] of [
['techTokens', tech, !!game.techTokens], ['strongholdCards', strongholds, !!game.strongholdCards],
] as const) if (enabled !== selectedModule) {
const step: PairedIxNexusSkillsModulesStep = { actor: game.host, action: { type, enabled } };
actions.push(step); game = stepPairedIxNexusSkillsModules(game, step);
}
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: PairedIxNexusSkillsModulesStep = { actor: p.id, action: { type: 'ready' } };
actions.push(step); game = stepPairedIxNexusSkillsModules(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.techTokens, tech); assert.equal(!!game.strongholdCards, strongholds);
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: PairedIxNexusSkillsModulesStep;
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 = pairedIxNexusSkillsModulesPlayer(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 = nextPairedIxNexusSkillsModulesStep(game); assert.ok(native); step = native;
if (!supplied && game.setupStage === 'traitors' && step.action.type === 'traitor') {
const choices = pairedIxNexusSkillsModulesPlayer(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');
staging.push('Scoped original remaining Traitor shuffle orders the undealt trainer before the actual native Face Dancer draw; no matching stock is manufactured or exchanged');
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) : stepPairedIxNexusSkillsModules(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;
return { initial, offered, afterSetup, game, ixians, tleilaxu, trainer, skill, actions, staging };
}
/** Actual initializer → original skill/Traitor/faction setup → Storm → END
* Mentat → qualifying alliance → closing draw. Only conserved order/counters/
* cards are staged; no wallet, ownership, hand face or earned receipt is assigned. */
export function createPairedIxNexusSkillsModulesFixture(options: PairedIxNexusSkillsModulesOptions = {}): PairedIxNexusSkillsModulesFixture {
const setup = initializePairedIxNexusSkillsModulesSetup(options);
const { initial, offered, afterSetup, ixians, tleilaxu, trainer, skill, actions, staging } = setup;
let game = setup.game;
const advanced = game.advanced, program = options.program ?? 'suboids', band = options.band ?? 'skilled';
const classics = game.players.filter(p => p.id !== ixians && p.id !== tleilaxu);
assert.ok(classics.length >= 2, 'Two classics must form a real alliance; two-seat setup does not authorize a fabricated closing draw');
const partners: [string, string] = [classics[0].id, classics[1].id];
game = advancePairedIxNexusSkillsModules(game, g => g.phase === 1 && clean(g), actions);
const afterFirstStorm = structuredClone(game);
orderSpice(game, ['land', 'land'], staging);
game = advancePairedIxNexusSkillsModules(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 = advancePairedIxNexusSkillsModules(game, g => g.phase === 8 && clean(g), actions);
if (game.strongholdCards) {
reserveBoard(game);
place(game, ixians, `arrakeen:${territory('arrakeen').sectors[0]}`, 1);
place(game, ixians, program === 'faceDance' ? `sietch_tabr:${territory('sietch_tabr').sectors[0]}` : MOBILE_LOCATION, 1);
staging.push('Before actual END Mentat: conserved Ix counters control only Arrakeen and Tabr/HMS; held Cards must be earned, not assigned');
}
let beforeFirstMentat!: Game, firstMentatStep!: PairedIxNexusSkillsModulesStep;
while (game.turn === 1) {
const step = nextPairedIxNexusSkillsModulesStep(game); assert.ok(step);
const before = structuredClone(game); actions.push(structuredClone(step)); game = stepPairedIxNexusSkillsModules(game, step);
if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = step; }
}
const afterFirstMentat = structuredClone(game);
game = advancePairedIxNexusSkillsModules(game, g => g.phase === 1 && clean(g), actions);
orderSpice(game, ['worm', 'land', 'land'], staging);
game = advancePairedIxNexusSkillsModules(game, g => !!g.nexus && !g.spiceWindow && !g.spiceResolution && clean(g), actions);
const beforeAlliance = structuredClone(game);
const allianceActions: PairedIxNexusSkillsModulesStep[] = partners.map((actor, i) => ({ actor, action: { type: 'alliance', target: partners[1 - i] } }));
for (const step of allianceActions) { actions.push(step); game = stepPairedIxNexusSkillsModules(game, step); }
game = advancePairedIxNexusSkillsModules(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: PairedIxNexusSkillsModulesStep[] = [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 = stepPairedIxNexusSkillsModules(game, step); }
game = advancePairedIxNexusSkillsModules(game, g => g.nexusCards?.phase?.stage !== 'drawing', actions);
const afterNexusDraw = structuredClone(game);
game = advancePairedIxNexusSkillsModules(game, g => g.phase === 5 && clean(g), actions);
reserveBoard(game);
const opponent = game.techTokens ? classics.find(p => ownedTech(game.techTokens, p.id).length)?.id
: classics.find(p => p.faction === 'guild')?.id ?? classics[0].id;
assert.ok(opponent, 'The real first Storm must allocate a classic opponent token');
assert.ok(!options.mobileBattle || game.strongholdCards && program === 'suboids');
const kind = options.mobileBattle ? MOBILE_STRONGHOLD : program === 'faceDance' ? 'sietch_tabr' : 'arrakeen';
const location = kind === MOBILE_STRONGHOLD ? MOBILE_LOCATION : `${kind}:${territory(kind).sectors[0]}`;
const 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);
if (options.mobileBattle) {
place(game, ixians, replacementSource, 1);
place(game, ixians, `sietch_tabr:${territory('sietch_tabr').sectors[0]}`, 1);
staging.push('Turn-two controlled HMS copy sources: Arrakeen and Tabr counters after actual two-hold Mentat; no later three-hold Mentat victory is suppressed');
}
staging.push('Before native movement completion: conserved six Ix counters (three Cyborg/three Suboid), six ordinary opponents and optional physical replacement sources; no phase, wallet or stock mutation');
const ix = pairedIxNexusSkillsModulesPlayer(game, ixians), stock = pairedIxNexusSkillsModulesPlayer(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(pairedIxNexusSkillsModulesPlayer(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 = advancePairedIxNexusSkillsModules(game, g => g.phase === 6 && clean(g), actions);
const beforeBattle = structuredClone(game), actor = game.active!; assert.ok(actor === ixians || actor === opponent);
const battleStep: PairedIxNexusSkillsModulesStep = { actor, action: { type: 'chooseBattle', territory: kind, target: actor === ixians ? opponent : ixians } };
actions.push(battleStep); game = stepPairedIxNexusSkillsModules(game, battleStep);
const planActions: PairedIxNexusSkillsModulesStep[] = [
{ 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,
planActions, actions, staging, sourceFacts: pairedIxNexusSkillsModulesSourceFacts(game, staging) };
}
export function preparePairedIxNexusSkillsModulesBattle(f: PairedIxNexusSkillsModulesFixture, state = f.game): Game {
let game = structuredClone(state);
for (let i = 0; i < 160; i++) {
const step = nextPairedIxNexusSkillsModulesStep(game);
if (!step) {
if (game.decision?.kind === 'strongholdCopy') {
assert.ok(game.decision.choices.includes('arrakeen'));
game = stepPairedIxNexusSkillsModules(game, { actor: game.decision.player, action: { type: 'decision', event: game.decision.event, stronghold: 'arrakeen' } });
continue;
}
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 = stepPairedIxNexusSkillsModules(game, step);
}
throw Error('Paired E1 native skill posture did not reach private plans');
}
export function beginPairedIxNexusSkillsModulesCunning(f: PairedIxNexusSkillsModulesFixture, state = preparePairedIxNexusSkillsModulesBattle(f)): Game {
const offer = viewGame(state, f.ixians).nexusSuboids?.offer;
assert.ok(offer); assert.equal(offer.blocked, null);
return stepPairedIxNexusSkillsModules(state, { actor: f.ixians, action: { type: 'nexusSuboids', event: offer.event } });
}
export function revealPairedIxNexusSkillsModulesBattle(f: PairedIxNexusSkillsModulesFixture,
options: { state?: Game; cunning?: boolean; plans?: PairedIxNexusSkillsModulesStep[] } = {}): Game {
let game = preparePairedIxNexusSkillsModulesBattle(f, options.state ?? f.game);
if (options.cunning) game = advancePairedIxNexusSkillsModules(beginPairedIxNexusSkillsModulesCunning(f, game), clean);
for (const step of options.plans ?? f.planActions) {
game = stepPairedIxNexusSkillsModules(game, step);
while (game.response || game.phaseOpening || game.decision?.kind === 'fullPlanOffer') game = stepPairedIxNexusSkillsModules(game);
}
assert.ok(game.battle?.revealed); return game;
}
export type PairedIxNexusSkillsModulesBoundary = 'losses' | 'rescue' | 'substitution' | 'cards' | 'tech' | 'faceDance' | 'complete';
/** `tech` stops at a real mandatory chooser if one exists. A singleton reward
* transfers automatically, so its observable boundary is the following actual
* Face Dance (or completed aftermath), never a manufactured extra decision. */
export function finishPairedIxNexusSkillsModulesBattle(state: Game,
stop: PairedIxNexusSkillsModulesBoundary = 'complete'): Game {
const kinds: Record<PairedIxNexusSkillsModulesBoundary, string> = {
losses: 'battleLosses', rescue: 'sukRescue', substitution: 'ixSubstitution',
cards: 'battleCards', tech: 'techToken', faceDance: 'faceDance', complete: '',
};
return advancePairedIxNexusSkillsModules(state, g =>
g.decision?.kind === kinds[stop] ||
stop === 'tech' && g.decision?.kind === 'faceDance' && !g.pendingTech ||
!g.battle && clean(g) && !g.pendingTreacheryDiscard);
}
export function finishPairedIxNexusSkillsModulesTurn(state: Game): Game {
return advancePairedIxNexusSkillsModules(state, g => g.turn > state.turn);
}
/** Actual skill posture and actual native Cunning force flags, not inferred cap arithmetic. */
export function quotePairedIxNexusSkillsModulesBattle(game: Game) {
const battle = game.battle; assert.ok(battle?.revealed);
const side = (actor: string): ResolutionCombatant => {
const p = pairedIxNexusSkillsModulesPlayer(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 : pairedIxNexusSkillsModulesPlayer(game, actor).ally!,
called: battle.traitorCalls[actor] ?? false, traitors: pairedIxNexusSkillsModulesPlayer(game, actor).traitors })),
participants: game.players, physicalCards: [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand)],
pendingAuditorPresent: false, pendingRetentionPresent: false });
}
/** Source facts are observations, never prescribed ownership/effects. */
export function pairedIxNexusSkillsModulesSourceFacts(game: Game, staging: string[] = []): PairedIxNexusSkillsModulesSourceFacts {
return {
sources: PAIRED_IX_NEXUS_SKILLS_MODULES_SOURCES,
staging: [...staging], turn: game.turn, phase: game.phase,
cardOwners: game.strongholdCards ? { ...game.strongholdCards.owners } : null,
controllers: strongholdControllers(game.players, !!game.mobileStronghold?.location),
tech: game.techTokens ? structuredClone(game.techTokens) : null,
stock: game.players.map(p => ({ player: p.id, faceDancers: structuredClone(p.faceDancers ?? []) })),
physicalForces: game.players.map(p => ({ player: p.id, reserves: p.reserves, tanks: p.tanks,
forces: { ...p.forces }, elites: structuredClone(p.elites) })),
};
}
@@ -0,0 +1,456 @@
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 { splitLocation, territory } from '../game/board';
import { presenceAt } from '../game/force-presence';
import { ownedTech } from '../game/tech-tokens';
import { quotePhaseResources } from '../game/phase-resource-quote';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
import { reserveShipmentCost } from '../game/shipment-price';
import { isPlanetologistBattleSpecialCard } from '../game/leader-skill-combat';
import {
advancePairedChoamSkillsModules as advance, advancePairedChoamSkillsModulesToPhase as toPhase,
advancePairedModulesBattleToDecision, advancePairedModulesRicheseToGuild,
allowPairedChoamSkillsModules as allow, assertPairedChoamSkillsModulesCustody as custody,
completePairedChoamSkillsModulesSetup, createPairedChoamNexusSkillsModulesFixture as fixture,
finishPairedChoamSkillsModulesMovement, finishPairedModulesSkillsBattle as finishBattle,
holdPairedModulesSkillsCard as hold, initializePairedChoamNexusSkillsModules,
openPairedModulesSkillsBattle as openBattle, pairedModulesRicheseGuildStep,
pairedModulesSkillsBattlePlans as plans, pairedModulesSkillsChoamCunningRequest as choamRequest,
pairedModulesSkillsClean as clean, pairedModulesSkillsPlayer as player,
pairedModulesSkillsRicheseCunningRequest as richeseRequest, pairedModulesSkillsSnapshot as snapshot,
pairedModulesSkillsStrongholdControls as controls, placePairedModulesSkillsForce as place,
quotePairedModulesSkillsBattle as quoteBattle, revealPairedModulesRichese as reveal,
stagePairedModulesSkillsPlanetologistRoute as stageRoute, stepPairedChoamSkillsModules as step,
type PairedChoamNexusSkillsModulesFixture,
} from './fixture-paired-choam-nexus-skills-modules';
const combinations = [
{ advanced: false, tech: true, strongholds: false },
{ advanced: true, tech: true, strongholds: false },
{ advanced: true, tech: false, strongholds: true },
{ advanced: true, tech: true, strongholds: true },
] as const;
function reject(game: Game, actor: string, action: Action): void {
const before = snapshot(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before, 'Rejected actions cannot consume a native invoice, physical card, marker, force, skill or reward');
}
function assertPair(f: PairedChoamNexusSkillsModulesFixture, shipped: Game, faceup: number): void {
const request = richeseRequest(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);
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);
assert.equal(shipped.nexusCards!.cards!.hands[f.richese], null);
assert.equal(shipped.nexusRicheseCunningLast!.stage, 'shipped');
assert.deepEqual(shipped.leaderSkills!.assignments, f.game.leaderSkills!.assignments);
assert.deepEqual(after.noField!.tokens, before.noField!.tokens);
custody(shipped);
}
/** Collection is an independent later receipt, never a battle subsidy. */
function assertBattleWallets(before: Game, after: Game): void {
const quote = quoteBattle(before);
const collection = after.phase === 7 && clean(after)
? quoteSpiceCollection({ ...after, players: after.players.map(p => ({ ...p, spice: 0 })) }).receipts : [];
for (const p of before.players) {
const payment = quote.payments.find(row => row.player === p.id)?.ownPayment ?? 0;
const choam = quote.choamIncome?.owner === p.id ? quote.choamIncome.amount : 0;
const stronghold = quote.strongholdIncome.filter(row => row.player === p.id).reduce((sum, row) => sum + row.amount, 0);
const bounty = quote.bounty?.player === p.id ? quote.bounty.amount : 0;
const city = collection.find(row => row.player === p.id);
assert.equal(player(after, p.id).spice, p.spice - payment + choam + stronghold + bounty + (city?.strongholds ?? 0) + (city?.collected ?? 0), p.faction);
}
}
for (const modules of combinations) void test(`paired Skills ${modules.advanced ? 'Advanced' : 'Basic'} Tech=${modules.tech} SH=${modules.strongholds}: original signed pair, one native invoice and once-only phase-end industry`, () => {
const f = fixture(modules), previous = player(f.game, f.richese).noField!.tokens.find(token => token.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);
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);
}
if (modules.tech) {
assert.ok(Object.values(f.afterSetup.techTokens!).every(token => token.owner === null));
assert.ok(Object.values(f.afterFirstStorm.techTokens!).every(token => token.owner && f.afterFirstStorm.players.some(p => p.id === token.owner)));
}
if (modules.strongholds) {
assert.equal(f.beforeFirstMentat.strongholdCards!.owners.habbanya_ridge_sietch, null);
assert.equal(f.beforeFirstMentat.strongholdCards!.owners.arrakeen, null);
assert.equal(f.afterFirstMentat.strongholdCards!.owners.habbanya_ridge_sietch, f.richese);
assert.equal(f.afterFirstMentat.strongholdCards!.owners.arrakeen, f.choam);
assert.equal(controls(f.game).habbanya_ridge_sietch, null);
assert.equal(f.game.strongholdCards!.owners.habbanya_ridge_sietch, f.richese, 'Leaving does not transfer a held physical card before END Mentat');
}
const request = richeseRequest(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, nexus: 'stale-pair' });
reject(f.game, f.richese, { ...request, smuggler: true });
reject(f.game, f.richese, { ...request, amount: 8 });
hold(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);
assert.equal(declared.response!.noFieldSkillProof, undefined);
assert.equal(declared.pendingShipment!.smugglerCompanion, undefined);
const receipt = declared.nexusRicheseCunningHistory!.at(-1)!;
assert.equal(receipt.frame, JSON.stringify(declared.pendingShipment), 'The original two-marker frame remains signed without an ordinary single-marker skill proof');
assert.equal(receipt.signature, JSON.stringify([receipt.event, receipt.owner, receipt.turn, receipt.phase, receipt.tokenEvent, receipt.frame, receipt.stage]));
assert.equal(player(declared, f.richese).spice, player(f.game, f.richese).spice);
assert.deepEqual(player(declared, f.richese).forces, player(f.game, f.richese).forces);
// Deliberately corrupt a saved invoice, not a legal staging grant.
const corrupted = snapshot(declared);
corrupted.pendingShipment!.cost = 0;
const responder = corrupted.players.find(p => !corrupted.response!.passed.includes(p.id))!.id;
reject(corrupted, responder, { type: 'passResponse' });
let shipped = advancePairedModulesRicheseToGuild(declared);
if (modules.advanced) {
assert.equal(shipped.decision?.kind, 'guildShipment');
assert.equal(shipped.pendingShipment!.cost, 1);
assert.equal(player(shipped, f.richese).spice, player(f.game, f.richese).spice);
reject(shipped, f.guild, { type: 'decision', allow: false });
shipped = allow(step(shipped, pairedModulesRicheseGuildStep(shipped)));
}
assertPair(f, shipped, 5);
reject(shipped, f.richese, request);
assert.equal(player(allow(shipped), f.guild).spice, player(shipped, f.guild).spice, 'Response continuation cannot repeat the original fee');
if (modules.tech) {
const token = shipped.techTokens!.heighliners;
assert.ok(token.owner);
const amount = ownedTech(f.game.techTokens, token.owner).length;
assert.equal(token.spice, amount);
assert.equal(token.triggeredTurn, shipped.turn);
assert.equal(player(shipped, token.owner).spice, player(f.game, token.owner).spice + (token.owner === f.richese ? -1 : token.owner === f.guild ? 1 : 0));
let another = shipped;
if (shipped.movementRemaining?.includes(f.choam)) {
const atChoam = advance(shipped, g => g.phase === 5 && g.active === f.choam && clean(g));
another = allow(applyAction(atChoam, f.choam, { type: 'ship', amount: 1, territory: 'arrakeen', sector: territory('arrakeen').sectors[0] }));
assert.equal(player(another, f.choam).spice, player(atChoam, f.choam).spice - 1);
assert.equal(player(another, f.guild).spice, player(atChoam, f.guild).spice + 1);
}
assert.equal(another.techTokens!.heighliners.spice, amount);
const credit = quotePhaseResources(another).credits.find(row => row.kind === 'tech' && row.token === 'heighliners');
assert.ok(credit);
const collection = quoteSpiceCollection(another).receipts.find(row => row.player === token.owner);
const ended = finishPairedChoamSkillsModulesMovement(another);
assert.equal(player(ended, token.owner).spice, credit.balance + (ended.phase === 7 ? (collection?.collected ?? 0) + (collection?.strongholds ?? 0) : 0));
assert.equal(ended.techTokens!.heighliners.spice, 0);
assert.equal(ended.techTokens!.heighliners.triggeredTurn, ended.turn);
custody(ended);
}
});
for (const modules of combinations) for (const trained of [false, true])
void test(`paired Skills ${modules.advanced ? 'Advanced' : 'Basic'} Tech=${modules.tech} SH=${modules.strongholds}: voluntary pair reveal, ${trained ? 'skilled' : 'normal'} Suk, native support and winner Tech`, () => {
const f = fixture(modules);
let game = allow(applyAction(f.game, f.richese, richeseRequest(f)));
game = reveal(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);
place(game, f.guild, f.location, 3, f.staging);
place(game, f.choam, `arrakeen:${territory('arrakeen').sectors[0]}`, 1, f.staging);
game = openBattle(game, f.richese, f.guild, { band: trained ? 'skilled' : 'normal' });
const battlePlans = plans(game, f.richese, f.guild, 2, trained);
assert.equal(battlePlans[0].action.leader === f.trainers.richese, trained);
for (const next of battlePlans) game = allow(step(game, next));
assert.ok(game.battle?.revealed);
const revealed = snapshot(game), quote = quoteBattle(game), loserTokens = ownedTech(game.techTokens, f.guild);
assert.equal(quote.winner, f.richese);
assert.equal(quote.result, 'normal');
assert.equal(quote.sukGraduate!.mode, trained ? 'skilled' : 'normal');
assert.equal(viewGame(game, f.richese).battle!.ownForces!.normal, 8);
assert.equal(quote.payments.find(row => row.player === f.richese)?.ownPayment ?? 0, modules.advanced ? 2 : 0);
assert.equal(quote.payments.find(row => row.player === f.richese)?.bankSupport ?? 0, 0);
assert.deepEqual(quote.choamIncome, modules.advanced ? { owner: f.choam, amount: 1 } : null);
if (trained) {
game = advancePairedModulesBattleToDecision(game, 'sukRescue');
assert.equal(game.decision?.kind, 'sukRescue');
if (game.decision?.kind !== 'sukRescue') throw Error('The real skilled casualty commitment must reach Suk');
const pending = snapshot(game), decision = game.decision;
reject(game, f.richese, { type: 'decision', event: 'stale-suk', choice: 0 });
assert.deepEqual(game, pending);
if (modules.tech && modules.strongholds) for (const difficulty of DIFFICULTIES) {
const view = viewGame(pending, f.richese);
view.players.find(p => p.id === f.richese)!.bot = difficulty;
const action = botActions(view)[0];
assert.ok(action?.type === 'decision' && typeof action.choice === 'number');
const rescue = decision.options[action.choice];
assert.ok(rescue);
const resolved = finishBattle(applyAction(pending, f.richese, action));
const kept = rescue.kept?.kind === 'normal' ? 1 : 0;
assert.equal(player(resolved, f.richese).forces[f.location], 6 + kept, difficulty);
assert.equal(player(resolved, f.richese).reserves, 12 + rescue.normal - kept, difficulty);
assert.equal(player(resolved, f.richese).tanks, 2 - rescue.normal, difficulty);
assert.equal(player(resolved, f.guild).tanks, 3, difficulty);
for (const token of loserTokens) assert.equal(resolved.techTokens![token].owner, f.richese, difficulty);
assertBattleWallets(revealed, resolved); custody(resolved);
}
if (modules.tech) for (const token of loserTokens)
assert.equal(game.techTokens![token].owner, f.guild, 'Original loser Tech cannot transfer before physical Suk rescue settles');
const choice = decision.options.findIndex(option => option.normal === 2 && option.elite === 0 && option.kept?.kind === 'normal');
assert.ok(choice >= 0);
game = applyAction(game, f.richese, { type: 'decision', event: decision.event, choice });
}
game = finishBattle(game);
assert.equal(game.lastBattleContext!.winner, f.richese);
assert.equal(player(game, f.richese).forces[f.location], trained ? 7 : 6);
assert.equal(player(game, f.richese).reserves, 13);
assert.equal(player(game, f.richese).tanks, trained ? 0 : 1);
assert.equal(player(game, f.guild).tanks, 3);
if (modules.tech) {
assert.ok(loserTokens.length > 0, 'Use original first-Storm loser custody, not a granted token');
for (const token of loserTokens) assert.equal(game.techTokens![token].owner, f.richese);
}
if (modules.strongholds) assert.equal(game.strongholdCards!.owners.habbanya_ridge_sietch, f.richese);
assertBattleWallets(revealed, game);
custody(game);
});
for (const modules of combinations) void test(`paired Tech=${modules.tech} SH=${modules.strongholds} ${modules.advanced ? 'Advanced' : 'Basic'}: reserve cap and voluntary reveal preserve actual previous-token restriction`, () => {
const f = fixture({ ...modules, reserveCap: 2 });
const shipped = allow(applyAction(f.game, f.richese, richeseRequest(f)));
assertPair(f, shipped, 2);
const revealed = reveal(shipped, f.richese), previous = player(revealed, f.richese).noField!.lastShipped!;
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);
let next = advance(revealed, g => g.turn === 3 && g.phase === 1 && clean(g));
const land = next.spiceDeck.filter(card => 'territory' in card).slice(0, 2);
assert.equal(land.length, 2);
for (const [index, card] of land.entries()) next.spiceDeck.splice(index, 0, next.spiceDeck.splice(next.spiceDeck.indexOf(card), 1)[0]);
f.staging.push('Conserved two unused original land cards before turn-three Spice; no played discard is restored.');
next = advance(next, g => g.turn === 3 && g.phase === 5 && g.active === f.richese && clean(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(token => token.value === 0)!;
const accepted = allow(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!.deployed!.tokenId, zero.id);
assert.equal(player(accepted, f.richese).noField!.lastShipped, zero.id);
assert.equal(player(accepted, f.richese).spice, player(next, f.richese).spice - 2);
custody(accepted);
});
void test('paired Skills/Tech/SH: native Karama stops only the pair; unpaid replacement cannot consume money, counters, markers or industry', () => {
const f = fixture(), karama = hold(f.game, f.guild, card => card.kind === 'special' && card.effect === 'karama', f.staging);
const before = snapshot(f.game), request = richeseRequest(f);
const pending = applyAction(f.game, f.richese, request);
const stopped = allow(applyAction(pending, 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).noField, player(before, f.richese).noField);
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(stopped.techTokens, before.techTokens);
assert.equal(player(stopped, f.richese).shipped, false);
assert.equal(stopped.discard.filter(card => card.id === karama.id).length, 1);
reject(stopped, f.richese, request);
const ordinary = allow(applyAction(stopped, f.richese, { type: 'ship', amount: 1, territory: 'habbanya_ridge_sietch', sector: territory('habbanya_ridge_sietch').sectors[0] }));
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(ordinary.techTokens!.heighliners.triggeredTurn, ordinary.turn);
const fresh = fixture(), amount = player(fresh.game, fresh.richese).spice;
assert.ok(amount > 0);
const unpaid = applyAction(fresh.game, fresh.richese, { type: 'bribe', target: fresh.guild, amount });
assert.equal(player(unpaid, fresh.richese).spice, 0);
reject(unpaid, fresh.richese, richeseRequest(fresh, unpaid));
assert.equal(unpaid.nexusCards!.cards!.hands[fresh.richese], 'richese');
custody(stopped); custody(ordinary); custody(unpaid);
});
for (const modules of combinations) void test(`paired Skills ${modules.advanced ? 'Advanced' : 'Basic'} Tech=${modules.tech} SH=${modules.strongholds}: actual CHOAM Special fuel precedes a separate ordinary Planetologist move`, () => {
const f = fixture(modules);
let game = advance(f.movementStart, g => g.phase === 5 && g.active === f.choam && clean(g));
const fuel = hold(game, f.choam, isPlanetologistBattleSpecialCard, f.staging);
hold(game, f.guild, card => card.effect === 'karama' && card.id !== fuel.id, f.staging);
const route = stageRoute(game, f.choam, f.staging), request = choamRequest(game, f.choam, fuel.id), before = snapshot(game);
reject(before, f.choam, route.action);
reject(before, f.choam, { ...request, nexus: 'stale-physical-fuel' });
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(card => card.id === fuel.id), fuel);
game = allow(game);
assert.equal(game.nexusChoamLast!.stage, 'complete');
assert.equal(game.discard.filter(card => card.id === fuel.id).length, 1);
assert.equal(player(game, f.choam).hand.some(card => card.id === fuel.id), false);
assert.equal(game.choamMovement!.bonus, 1);
reject(game, f.choam, request);
reject(game, f.choam, { ...route.action, noField: 'not-ordinary-counters' });
const moved = allow(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(card => card.id === fuel.id).length, 1, 'Moving does not replay the already spent printed Special');
assert.deepEqual(moved.leaderSkills!.assignments, before.leaderSkills!.assignments);
for (const p of before.players) assert.equal(player(moved, p.id).spice, p.spice);
custody(moved);
if (modules.tech && modules.strongholds) {
// Conserved ordinary counter source for a later native battle; never revive fuel.
place(moved, f.choam, route.to, 3, f.staging);
place(moved, f.guild, route.to, 3, f.staging);
let battle = openBattle(moved, f.choam, f.guild, { territory: splitLocation(route.to).territory, band: 'skilled' });
const battlePlans = plans(battle, f.choam, f.guild, 1, true);
reject(battle, f.choam, { ...battlePlans[0].action, weapon: fuel.id });
for (const next of battlePlans) battle = allow(step(battle, next));
const revealed = snapshot(battle), quote = quoteBattle(battle);
const bonus = battle.battle!.attacker === f.choam ? quote.leaderSkillBonuses.attacker : quote.leaderSkillBonuses.defender;
assert.equal(bonus.applied.some(applied => applied.skill === 'planetologist'), false, 'Spent Kulon fuel cannot become an artificial Special-card battle bonus');
assert.equal(quote.winner, f.choam);
const finished = finishBattle(battle);
assert.equal(player(finished, f.choam).forces[route.to], 3);
assert.equal(player(finished, f.choam).tanks, 1);
assert.equal(player(finished, f.guild).tanks, 3);
assert.equal(finished.discard.filter(card => card.id === fuel.id).length, 1);
assertBattleWallets(revealed, finished); custody(finished);
}
});
void test('paired Skills/Tech/SH: canceled CHOAM Cunning retains its real Special and grants neither Kulon range nor a replay', () => {
const f = fixture();
let game = advance(f.movementStart, g => g.phase === 5 && g.active === f.choam && clean(g));
const fuel = hold(game, f.choam, isPlanetologistBattleSpecialCard, f.staging);
const karama = hold(game, f.guild, card => card.kind === 'special' && card.effect === 'karama' && card.id !== fuel.id, f.staging);
const route = stageRoute(game, f.choam, f.staging), before = snapshot(game), request = choamRequest(game, f.choam, fuel.id);
game = applyAction(game, f.choam, request);
game = allow(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(card => card.id === fuel.id), fuel);
assert.equal(game.discard.some(card => card.id === fuel.id), false);
assert.equal(game.discard.filter(card => card.id === karama.id).length, 1);
assert.equal(game.choamMovement?.bonus ?? 0, 0);
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('paired ordinary Suk in retained Arrakeen uses original bank support while CHOAM still earns declared-support income', () => {
const f = fixture({ arrakeenOwner: 'richese' });
assert.equal(f.beforeFirstMentat.strongholdCards!.owners.arrakeen, null);
assert.equal(controls(f.beforeFirstMentat).arrakeen, f.richese);
assert.equal(f.afterFirstMentat.strongholdCards!.owners.arrakeen, f.richese);
let game = reveal(allow(applyAction(f.game, f.richese, richeseRequest(f))), f.richese);
const location = `arrakeen:${territory('arrakeen').sectors[0]}`, richese = player(game, f.richese);
// Conserved alternate ordinary battle position, not a claimed played move.
const group = richese.forces[f.location];
delete richese.forces[f.location];
richese.forces[location] = group;
f.staging.push(`Conserved ${group} revealed Richese counters: ${f.location} → ${location}; not native movement history.`);
place(game, f.guild, location, 3, f.staging);
assert.equal(controls(game).arrakeen, null, 'Contested current control is not a transfer of the held physical Arrakeen card');
assert.equal(game.strongholdCards!.owners.arrakeen, f.richese);
game = openBattle(game, f.richese, f.guild, { territory: 'arrakeen', band: 'skilled' });
for (const next of plans(game, f.richese, f.guild, 2, true)) game = allow(step(game, next));
const revealed = snapshot(game), quote = quoteBattle(game), payment = quote.payments.find(row => row.player === f.richese)!;
assert.equal(viewGame(game, f.richese).battle!.strongholdEffects[f.richese], 'arrakeen');
assert.equal(payment.ownPayment, 0);
assert.equal(payment.bankSupport, 2);
assert.deepEqual(quote.choamIncome, { owner: f.choam, amount: 1 }, 'Native income counts original declared support, including the actual bank-funded leg');
assert.equal(quote.sukGraduate!.mode, 'skilled');
game = finishBattle(game);
assert.equal(player(game, f.richese).forces[location], 7);
assert.equal(player(game, f.richese).reserves, 13);
assert.equal(player(game, f.richese).tanks, 0);
assert.equal(player(game, f.guild).tanks, 3);
assert.equal(game.strongholdCards!.owners.arrakeen, f.richese);
assertBattleWallets(revealed, game); custody(game);
});
void test('paired retained Habbanya wins a tied ordinary battle only after actual reveal; mixed marker plans remain excluded', () => {
const f = fixture();
let game = allow(applyAction(f.game, f.richese, richeseRequest(f)));
place(game, f.guild, f.location, 6, f.staging);
const beforeMovementEnd = snapshot(game);
game = finishPairedChoamSkillsModulesMovement(game);
assert.equal(game.phase, 6);
assert.ok(game.active === f.richese || game.active === f.guild);
const beforeMixed = snapshot(game);
assert.throws(() => applyAction(game, game.active!, { type: 'chooseBattle', territory: 'habbanya_ridge_sietch',
target: game.active === f.richese ? f.guild : f.richese }), /Mixed ordinary-force and No-Field/);
assert.deepEqual(game, beforeMixed);
game = reveal(beforeMovementEnd, f.richese);
game = openBattle(game, f.richese, f.guild, { band: 'skilled' });
const r = player(game, f.richese), g = player(game, f.guild);
const rLeader = r.leaders.filter(leader => !leader.dead && leader.id !== f.trainers.richese).sort((a, b) => b.strength - a.strength)[0];
const gTrainer = game.leaderSkills!.assignments.find(assignment => assignment.owner === f.guild)!.leader;
const gLeader = g.leaders.filter(leader => !leader.dead && leader.id !== gTrainer).sort((a, b) => b.strength - a.strength)[0];
assert.ok(rLeader && gLeader);
const rDial = Math.max(0, gLeader.strength - rLeader.strength) + 1;
const gDial = Math.max(0, rLeader.strength - gLeader.strength) + 1;
assert.ok(rDial < 8 && gDial <= 6 && r.spice >= rDial && g.spice >= gDial);
for (const [actor, leader, dial] of [[f.richese, rLeader.id, rDial], [f.guild, gLeader.id, gDial]] as const)
game = allow(applyAction(game, actor, { type: 'battlePlan', leader, dial, support: dial, weapon: null, defense: null }));
const revealed = snapshot(game), quote = quoteBattle(game);
assert.equal(rDial + rLeader.strength, gDial + gLeader.strength);
assert.equal(quote.winner, f.richese);
assert.equal(quote.sukGraduate, undefined, 'Hidden unselected Suk does not rescue casualties');
assert.equal(viewGame(game, f.richese).battle!.strongholdEffects[f.richese], 'habbanya_ridge_sietch');
game = finishBattle(game);
assert.equal(player(game, f.richese).forces[f.location], 8 - rDial);
assert.equal(player(game, f.richese).tanks, rDial);
assert.equal(player(game, f.guild).tanks, 6);
assert.equal(game.strongholdCards!.owners.habbanya_ridge_sietch, f.richese);
assertBattleWallets(revealed, game); custody(game);
});
void test('original supplied skill offers and dealt hands survive module continuation; all four native policies pay one real pair tariff', () => {
let lobby = createGame('AUTHPAIREDSKILLSMODULES', 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'));
lobby = applyAction(lobby, lobby.host, { type: 'techTokens', enabled: true });
lobby = applyAction(lobby, lobby.host, { type: 'strongholdCards', enabled: true });
const setup = initializePairedChoamNexusSkillsModules({ initial: lobby }), saved = snapshot(setup);
const f = fixture({ initial: setup });
assert.deepEqual(setup, saved);
assert.deepEqual(f.initial, saved, 'Continuation cannot recreate saved offers or the first native deal');
assert.deepEqual([f.richese, f.choam, f.guild, f.observer], ['auth-r', 'auth-c', 'auth-g', 'auth-a']);
for (const p of saved.players) assert.deepEqual(player(f.afterSetup, p.id).hand, p.hand);
const absent = setup.leaderSkills!.deck.find(skill => !setup.leaderSkills!.offers[f.richese].cards.includes(skill))!;
assert.throws(() => fixture({ initial: setup, richeseSkill: absent }));
assert.deepEqual(setup, saved);
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 = allow(applyAction(f.game, f.richese, action));
assert.equal(shipped.nexusRicheseCunningLast!.stage, 'shipped', difficulty);
assert.ok(typeof action.territory === 'string');
const tariff = reserveShipmentCost({ faction: 'richese', halfRate: false }, territory(action.territory).type, 1);
assert.equal(player(shipped, f.richese).spice, player(f.game, f.richese).spice - tariff);
assert.equal(player(shipped, f.guild).spice, player(f.game, f.guild).spice + tariff);
assert.equal(shipped.nexusCards!.cards!.hands[f.richese], null);
custody(shipped);
}
});
void test('two-seat original SH/Skills setup plays actual Storm without pretending an unallied closing Nexus draw is available', () => {
const setup = initializePairedChoamNexusSkillsModules({ factions: ['richese', 'choam'], tech: false, strongholds: true });
const saved = snapshot(setup), playing = completePairedChoamSkillsModulesSetup(setup);
for (const p of saved.players) assert.deepEqual(player(playing, p.id).hand, p.hand);
const firstStorm = toPhase(playing, 1);
assert.equal(firstStorm.turn, 1);
assert.equal(firstStorm.nexusCards!.cards!.deck.length, 12);
assert.ok(Object.values(firstStorm.nexusCards!.cards!.hands).every(card => card === null));
assert.throws(() => fixture({ initial: saved }), /Both own Cunning draws require/);
assert.deepEqual(setup, saved);
custody(firstStorm);
});
@@ -0,0 +1,351 @@
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 { DIFFICULTIES } from '../game/bot-profiles';
import { ownedTech } from '../game/tech-tokens';
import { strongholdBenefit, strongholdControllers } from '../game/stronghold-cards';
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 {
advancePairedIxNexusSkillsModules as advance, beginPairedIxNexusSkillsModulesCunning as cunning,
createPairedIxNexusSkillsModulesFixture as fixture, finishPairedIxNexusSkillsModulesBattle as finish,
finishPairedIxNexusSkillsModulesTurn as finishTurn, initializePairedIxNexusSkillsModulesSetup as setup,
pairedIxNexusSkillsModulesPlayer as player, preparePairedIxNexusSkillsModulesBattle as prepare,
quotePairedIxNexusSkillsModulesBattle as quoteBattle, revealPairedIxNexusSkillsModulesBattle as reveal,
stepPairedIxNexusSkillsModules as step, type PairedIxNexusSkillsModulesFixture,
} from './fixture-paired-ix-nexus-skills-modules';
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 = quoteBattle(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;
const income = quote.strongholdIncome.filter(r => r.player === p.id).reduce((sum, r) => sum + r.amount, 0);
assert.equal(player(settled, p.id).spice, p.spice - paid + bounty + income + city(settled, p.id), p.faction);
}
}
function chooseCyborgLosses(state: Game, f: PairedIxNexusSkillsModulesFixture): Game {
const losses = finish(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 step(losses, { actor: f.ixians, action: { type: 'decision', choice } });
}
function dance(f: PairedIxNexusSkillsModulesFixture, pending: Game): Game {
assert.equal(pending.decision?.kind, 'faceDance');
const count = player(pending, f.ixians).forces[f.location]; assert.ok(count >= 2);
return step(pending, { actor: f.tleilaxu, action: { type: 'decision', reveal: true,
sources: { [f.replacementSource]: 2, reserves: count - 2 }, sector: territory('sietch_tabr').sectors[0] } });
}
function reward(f: PairedIxNexusSkillsModulesFixture, game: Game): void {
assert.equal(game.lastBattleContext!.winner, f.ixians);
if (!f.game.techTokens) return;
const tokens = ownedTech(f.game.techTokens, f.opponent); assert.equal(tokens.length, 1);
assert.equal(game.techTokens![tokens[0]].owner, f.ixians);
assert.equal(game.techTokens![tokens[0]].spice, f.game.techTokens![tokens[0]].spice);
assert.equal(ownedTech(game.techTokens, f.opponent).length, 0);
}
const MODES = [
{ advanced: false, tech: true, strongholds: false },
{ advanced: true, tech: true, strongholds: false },
{ advanced: true, tech: false, strongholds: true },
{ advanced: true, tech: true, strongholds: true },
] as const;
for (const mode of MODES) for (const band of ['normal', 'skilled'] as const) {
const advanced = mode.advanced;
void test(`paired E1 Skills/modules ${JSON.stringify(mode)} ${band} Warmaster: full unsupported Suboids, real bonus and typed losses`, () => {
const f = fixture({ ...mode, 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');
if (mode.strongholds) {
assert.equal(f.beforeFirstMentat.strongholdCards!.owners.arrakeen, null);
const claimed = step(f.beforeFirstMentat, f.firstMentatStep);
assert.equal(claimed.strongholdCards!.owners.arrakeen, f.ixians);
assert.equal(claimed.turn, 2);
}
custody(f.afterNexusDraw, f.afterSetup);
const ready = prepare(f), offer = viewGame(ready, f.ixians).nexusSuboids!.offer!;
reject(ready, f.tleilaxu, { type: 'nexusSuboids', event: offer.event });
const active = cunning(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 = reveal(f, { state: active });
const quote = quoteBattle(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 = quoteBattle(reveal(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 = finish(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 = finish(step(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); reward(f, settled); custody(settled, f.afterSetup);
});
void test(`paired E1 Skills/modules ${JSON.stringify(mode)} ${band} Suk: physical rescue precedes equal Suboid substitution, paid once`, () => {
const f = fixture({ ...mode, band, skill: 'suk-graduate' });
const revealed = reveal(f, { cunning: true }), quote = quoteBattle(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, mode.strongholds ? 0 : 1); assert.equal(payment.bankSupport, mode.strongholds ? 1 : 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 = finish(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 = step(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 = finish(selected, 'substitution');
assert.equal(pending.decision?.kind, 'ixSubstitution');
assert.deepEqual(pending.techTokens, revealed.techTokens, 'Physical rescue/substitution must precede original Tech transfer');
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 && !mode.strongholds ? 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 = step(pending, { actor: f.ixians, action: { type: 'decision', sources: { [f.location]: 2 }, recover: { [f.location]: 2 } } });
const settled = finish(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); reward(f, settled); custody(settled, f.afterSetup);
});
}
for (const mode of MODES) void test(`paired E1 Skills/modules ${JSON.stringify(mode)} Face Dance: Suk/card cleanup/rewards finish first; partial Cunning draws before retirement`, () => {
const f = fixture({ ...mode, program: 'faceDance', skill: 'suk-graduate' });
f.planActions[1].action.dial = 1;
f.planActions[1].action.support = mode.advanced ? 1 : 0;
const revealed = reveal(f, { cunning: true }), quote = quoteBattle(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 = finish(revealed, 'cards');
assert.equal(cards.decision?.kind, 'battleCards');
reject(cards, f.tleilaxu, { type: 'decision', reveal: true, sources: { reserves: 1 }, sector: territory('sietch_tabr').sectors[0] });
assert.equal(cards.lastBattleContext!.sukRescue!.completed, true);
const defense = f.planActions[0].action.defense as string;
const cleaned = step(cards, { actor: f.ixians, action: { type: 'decision', discard: [defense] } });
const rewarded = finish(cleaned, 'tech');
reward(f, rewarded);
const pending = finish(rewarded, '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); reward(f, pending);
assert.ok(!pending.pendingTech, 'Mandatory native Tech reward finishes before matching Face Dance');
if (mode.strongholds) assert.deepEqual(quote.strongholdIncome, [{ player: f.ixians, amount: 1 }]);
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('sietch_tabr').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));
assert.deepEqual(after.techTokens, pending.techTokens, 'Replacement cannot confiscate the earned token');
if (mode.strongholds) {
assert.equal(strongholdControllers(after.players, true).sietch_tabr, f.tleilaxu);
assert.equal(strongholdBenefit(after.strongholdCards, f.ixians, 'sietch_tabr'), 'sietch_tabr');
assert.equal(strongholdBenefit(after.strongholdCards, f.tleilaxu, 'sietch_tabr'), null);
}
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 = step(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 = advance(replaced, g => g.phase === 8 && !g.phaseOpening && !g.response && !g.decision);
const ordinary = step(mentat, { actor: f.tleilaxu, action: { type: 'replaceFaceDancer', leader: untouched[0].leader } });
const done = advance(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 = finishTurn(done);
if (mode.strongholds) assert.equal(turn.strongholdCards!.owners.sietch_tabr, f.tleilaxu);
assert.equal(turn.leaderSkills!.deck.filter(s => s === f.skill).length, 1); custody(turn, f.afterSetup);
});
void test('held Arrakeen and original HMS copy subsidize the payer, not the supported Suk casualty commitment', () => {
const f = fixture({ tech: true, strongholds: true, mobileBattle: true });
const copy = advance(f.game, g => g.decision?.kind === 'strongholdCopy');
assert.equal(copy.decision?.kind, 'strongholdCopy');
reject(copy, f.ixians, f.planActions[0].action);
const prepared = prepare(f, copy);
assert.equal(viewGame(prepared, f.opponent).battle!.strongholdEffects[f.ixians], 'arrakeen');
const revealed = reveal(f, { state: prepared, cunning: true }), quote = quoteBattle(revealed);
assert.equal(quote.payments.find(p => p.player === f.ixians)!.ownPayment, 0);
assert.equal(quote.payments.find(p => p.player === f.ixians)!.bankSupport, 1);
const rescue = finish(chooseCyborgLosses(revealed, f), 'rescue');
if (rescue.decision?.kind !== 'sukRescue') throw Error('Original copy-funded Suk rescue is required');
assert.deepEqual(rescue.pendingSukRescue!.losses, { normal: 0, elite: 3, paidNormal: 0, paidElite: 1 });
assert.equal(player(rescue, f.ixians).tanks, player(revealed, f.ixians).tanks);
const settled = finish(rescue);
money(revealed, settled); reward(f, settled); custody(settled, f.afterSetup);
});
void test('own sealed plan cannot consume Cunning and four legal policies preserve actual paid plans, typed rescue and Face Dance sources', () => {
const f = fixture({ tech: true, strongholds: true, program: 'faceDance' });
const prepared = prepare(f), offer = viewGame(prepared, f.ixians).nexusSuboids!.offer!;
const sealed = step(prepared, f.planActions[0]);
reject(sealed, f.ixians, { type: 'nexusSuboids', event: offer.event });
const revealed = reveal(f, { cunning: true });
const rescue = finish(chooseCyborgLosses(revealed, f), 'rescue');
if (rescue.decision?.kind !== 'sukRescue') throw Error('Original typed policy rescue is required');
const face = finish(revealed, 'faceDance');
for (const difficulty of DIFFICULTIES) {
const opening = viewGame(prepared, f.ixians); opening.players.find(p => p.id === f.ixians)!.bot = difficulty;
const cunningAction = botActions(opening).find(a => a.type === 'nexusSuboids'); assert.ok(cunningAction);
const active = advance(step(prepared, { actor: f.ixians, action: cunningAction }), g => !g.response && !g.decision && !g.phaseOpening);
const own = viewGame(active, f.ixians); own.players.find(p => p.id === f.ixians)!.bot = difficulty;
const plan = botActions(own).find(a => a.type === 'battlePlan'); assert.ok(plan);
const committed = step(active, { actor: f.ixians, action: plan });
assert.ok(committed.battle!.plans[f.ixians]);
assert.equal(player(committed, f.ixians).spice, player(active, f.ixians).spice);
const rescueView = viewGame(rescue, f.ixians); rescueView.players.find(p => p.id === f.ixians)!.bot = difficulty;
const rescueAction = botActions(rescueView).find(a => a.type === 'decision'); assert.ok(rescueAction);
const saved = step(rescue, { actor: f.ixians, action: rescueAction });
assert.equal(player(saved, f.ixians).tanks, player(revealed, f.ixians).tanks);
assert.equal(player(saved, f.ixians).forces[f.location], 4);
custody(finish(saved), f.afterSetup);
const view = viewGame(face, f.tleilaxu); view.players.find(p => p.id === f.tleilaxu)!.bot = difficulty;
const action = botActions(view).find(a => a.type === 'decision'); assert.ok(action && action.reveal === true);
const after = step(face, { actor: f.tleilaxu, action });
assert.equal(player(after, f.ixians).forces[f.location] ?? 0, 0);
assert.ok(player(after, f.tleilaxu).forces[f.location] > 0);
assert.equal(after.leaderSkills!.assignments.some(a => a.leader === f.trainer), false);
assert.deepEqual(after.techTokens, face.techTokens); custody(after, f.afterSetup);
}
});
void test('original authenticated saved hands/offers survive module continuation; absent skill and foreign disc are rejected', () => {
const lobby = createGame('PAIREDIXSKILLSMODULESAUTH', 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 = fixture({ initial: lobby, tech: true, strongholds: true, skill: 'suk-graduate' });
const saved = structuredClone(fresh.offered), offer = saved.leaderSkills!.offers[fresh.ixians];
reject(saved, fresh.ixians, { type: 'leaderSkill', event: offer.event, skill: 'suk-graduate', leader: player(saved, fresh.opponent).leaders[0].id });
const unavailable = LEADER_SKILL_CARDS.find(c => !offer.cards.includes(c.id))!.id;
assert.throws(() => fixture({ initial: saved, skill: unavailable }));
const continued = fixture({ initial: saved, skill: 'suk-graduate' });
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);
const ready = prepare(continued), offer2 = viewGame(ready, 'http-ix').nexusSuboids!.offer!;
reject(ready, 'http-tl', { type: 'nexusSuboids', event: offer2.event });
const active = cunning(continued, ready);
assert.equal(active.nexusSuboidLast!.owner, 'http-ix');
custody(active, continued.afterSetup);
});
void test('two native seats may initialize Strongholds/Skills but an unallied closing draw cannot be fabricated', () => {
const lobby = createGame('PAIREDIXTWO', newPlayer('ix', 'Ix', 'ixians'), true, ['ix']);
joinGame(lobby, newPlayer('tl', 'Tleilaxu', 'tleilaxu'));
const original = setup({ initial: lobby, tech: false, strongholds: true, skill: 'suk-graduate' });
const storm = advance(original.game, g => g.phase === 1 && !g.phaseOpening && !g.response && !g.decision);
assert.equal(storm.turn, 1);
assert.ok(Object.values(storm.nexusCards!.cards!.hands).every(c => c === null));
reject(storm, 'ix', { type: 'nexusCardChoice', turn: 1, card: null, choice: 'draw', ownRedraws: 0 });
assert.throws(() => fixture({ initial: original.offered }), /Two classics/);
});