Compose paired Nexus Skills with original Tech and Strongholds
This commit is contained in:
1 parent
449e27b415
commit
2d24f43721
14 files changed
+1709
-21
No files matched your search
@@ -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/);
|
||||
});
|
||||
Reference in new issue
Block a user