Compose paired Nexus and native skills with signed marker shipments

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

No files matched your search

+405
View File
@@ -0,0 +1,405 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, handLimit, initializeLeaderSkillsGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game, type GameView, type Player,
} from '../game/engine';
import { gameDistance, splitLocation, TERRITORIES, territory } from '../game/board';
import { treacheryDeck, type Card } from '../game/cards';
import type { FactionId } from '../game/catalog';
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
import { pairedNexusLeaderSkillsProfile } from '../game/leader-skill-profile';
import { validateLeaderSkills } from '../game/leader-skills';
import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards';
import { richeseCards } from '../game/richese-cards';
import { quoteBattleResolution, type ResolutionCombatant } from '../game/battle-resolution-quote';
import { nextNativeSkillsStrongholdStep } from './fixture-native-skills-stronghold';
export type PairedChoamNexusSkillsStep = { actor: string; action: Action };
export type PairedChoamNexusSkillsOptions = {
/** Original fresh lobby OR initialized turn-one setup; retain hands, offers and IDs. */
initial?: Game;
advanced?: boolean;
/** Fresh-lobby roster only. Setup supports every original two-to-six-seat pair. */
factions?: readonly FactionId[];
seatIds?: readonly string[];
/** Omit on a supplied setup to choose its actual available offer, never replace it. */
richeseSkill?: LeaderSkillId;
choamSkill?: LeaderSkillId;
reserveCap?: number;
};
export type PairedChoamNexusSkillsFixture = {
initial: Game;
afterSetup: Game;
afterFirstStorm: Game;
firstShipment: { before: Game; step: PairedChoamNexusSkillsStep; after: Game };
beforeFirstMentat: Game;
firstMentatStep: PairedChoamNexusSkillsStep;
afterFirstMentat: Game;
beforeAlliance: Game;
allianceActions: PairedChoamNexusSkillsStep[];
beforeClosingDraw: Game;
closingDraws: { before: Game; step: PairedChoamNexusSkillsStep; after: Game }[];
movementStart: Game;
game: Game;
richese: string;
choam: string;
guild: string;
observer: string;
location: string;
trainers: { richese: string; choam: string };
actions: PairedChoamNexusSkillsStep[];
staging: string[];
};
export const pairedSkillsSnapshot = (game: Game): Game => structuredClone(game);
export const pairedSkillsClean = (game: Game): boolean => !game.phaseOpening && !game.response && !game.decision;
export function pairedSkillsPlayer(game: Game, actor: string): Player {
const player = game.players.find(p => p.id === actor);
assert.ok(player, `Missing original paired E2 skill actor ${actor}`);
return player;
}
/** Control only the original all14 shuffle before native starting deals. Generic
* requests use the bound native method and still return the supplied array. */
function setupEntropy<T>(operation: () => T, selected: LeaderSkillId[]): T {
assert.equal(new Set(selected).size, selected.length);
const working: LeaderSkillId[] = LEADER_SKILL_CARDS.map(c => c.id);
const remainder = working.filter(c => !selected.includes(c));
const desired: LeaderSkillId[] = selected.flatMap(c => [c, remainder.shift()!]);
desired.push(...remainder);
const rolls: number[] = [];
for (let i = working.length - 1; i > 0; i--) {
const j = working.indexOf(desired[i]);
assert.ok(j >= 0 && j <= i);
rolls.push(Math.floor((j + 0.5) * 0x100000000 / (i + 1)));
[working[i], working[j]] = [working[j], working[i]];
}
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const native = crypto.getRandomValues.bind(crypto);
let cursor = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1) array[0] = rolls[cursor++] ?? 0xffffffff;
else Reflect.apply(native, crypto, [array]);
return array;
};
try { return operation(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
export function initializePairedChoamNexusSkills(options: PairedChoamNexusSkillsOptions = {}): Game {
const factions: readonly FactionId[] = options.factions ?? ['richese', 'choam', 'guild', 'atreides'];
let game = options.initial ? pairedSkillsSnapshot(options.initial)
: createGame('PAIREDCHOAMNEXUSSKILLS', newPlayer(options.seatIds?.[0] ?? factions[0], factions[0], factions[0]), options.advanced ?? true, ['choam']);
if (!options.initial) for (const faction of factions.slice(1))
joinGame(game, newPlayer(options.seatIds?.[game.players.length] ?? faction, faction, faction));
if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced);
assert.ok(game.status === 'lobby' || game.status === 'setup' && game.turn === 1 && game.phase === 0 && game.leaderSkills?.assignments.length === 0,
'Continue only the original lobby or initialized unassigned turn-one skill setup');
if (game.status === 'lobby' && !game.nexusCards) game.nexusCards = { cards: null, phase: null };
assert.ok(pairedNexusLeaderSkillsProfile(game));
assert.deepEqual(game.expansions, ['choam']);
if (game.status === 'setup') {
assert.equal(game.setupStage, 'leaderSkills');
assertPairedChoamSkillsCustody(game);
return game;
}
for (const p of game.players) if (!p.ready) game = applyAction(game, p.id, { type: 'ready' });
const richeseSkill = options.richeseSkill ?? 'suk-graduate';
const choamSkill = options.choamSkill ?? 'planetologist';
const passive: LeaderSkillId[] = ['sandmaster', 'warmaster', 'killer-medic', 'prana-bindu-adept', 'master-of-assassins', 'swordmaster-of-ginaz']
.filter(c => c !== richeseSkill && c !== choamSkill) as LeaderSkillId[];
const selected: LeaderSkillId[] = game.players.map(p => p.faction === 'richese' ? richeseSkill : p.faction === 'choam' ? choamSkill : passive.shift()!);
return setupEntropy(() => initializeLeaderSkillsGameForAudit(game), selected);
}
export function nextPairedChoamSkillsStep(game: Game): PairedChoamNexusSkillsStep | null {
if (game.status === 'setup' && game.setupStage === 'leaderSkills') {
const actor: string = Object.keys(game.leaderSkills!.offers)[0];
assert.ok(actor);
const offer: NonNullable<GameView['leaderSkills']> = viewGame(game, actor).leaderSkills!;
const skill: LeaderSkillId | undefined = offer.offer!.cards.find(c => !offer.unavailableSkills?.[c]);
const eligible = new Set(offer.eligibleLeaders.map(l => l.id));
const leader = pairedSkillsPlayer(game, actor).leaders.filter(l => eligible.has(l.id)).sort((a, b) => b.strength - a.strength)[0];
assert.ok(skill && leader);
return { actor, action: { type: 'leaderSkill', event: offer.offer!.event, skill, leader: leader.id } };
}
if (pairedSkillsClean(game) && game.nexusCards?.phase?.stage === 'drawing') {
const phase = game.nexusCards.phase;
const actor: string | undefined = phase.eligible.find(id => !phase.done.includes(id));
if (actor) return { actor, action: { type: 'nexusCardChoice', turn: game.turn,
card: game.nexusCards.cards!.hands[actor], choice: 'keep', ownRedraws: 0 } };
}
if (!game.phaseOpening && !game.response) {
const decision = game.decision;
if (decision?.kind === 'leaderSkillVisibility') return { actor: decision.player,
action: { type: 'leaderSkillVisibility', event: decision.event, hide: false } };
if (decision?.kind === 'wormRide') return { actor: decision.player, action: { type: 'decision', accept: false } };
if (decision?.kind === 'nexusFremenCunningOffer' || decision?.kind === 'nexusFremenCunningRide')
return { actor: decision.player, action: { type: 'decision', event: decision.event, accept: false } };
if (decision?.kind === 'nexusChoamInspection')
return { actor: decision.player, action: { type: 'decision', event: decision.event, inspect: false } };
}
return nextNativeSkillsStrongholdStep(game);
}
export function stepPairedChoamSkills(game: Game, next = nextPairedChoamSkillsStep(game), actions?: PairedChoamNexusSkillsStep[]): Game {
assert.ok(next, 'The original paired skill continuation needs a human plan');
actions?.push(structuredClone(next));
return applyAction(game, next.actor, next.action);
}
export function advancePairedChoamSkills(game: Game, until: (state: Game) => boolean, actions?: PairedChoamNexusSkillsStep[]): Game {
for (let n = 0; n < 1800; n++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
game = stepPairedChoamSkills(game, undefined, actions);
}
throw Error('Original paired E2 Skills continuation did not reach its requested boundary');
}
export function advancePairedChoamSkillsToPhase(game: Game, phase: number, actions?: PairedChoamNexusSkillsStep[]): Game {
const turn = game.turn;
return advancePairedChoamSkills(game, state => {
assert.equal(state.turn, turn, 'Do not chase an auto-skipped phase into the next turn');
return state.phase >= phase && pairedSkillsClean(state);
}, actions);
}
export function completePairedChoamSkillsSetup(game: Game, options: PairedChoamNexusSkillsOptions = {}, actions?: PairedChoamNexusSkillsStep[]): Game {
for (let n = 0; game.status === 'setup' && n < 200; n++) {
if (game.setupStage === 'leaderSkills') {
const actor: string = Object.keys(game.leaderSkills!.offers)[0];
const p = pairedSkillsPlayer(game, actor);
const requested: LeaderSkillId | undefined = p.faction === 'richese' ? options.richeseSkill : p.faction === 'choam' ? options.choamSkill : undefined;
const offer: NonNullable<GameView['leaderSkills']> = viewGame(game, actor).leaderSkills!;
const skill: LeaderSkillId | undefined = requested ?? offer.offer!.cards.find(c => !offer.unavailableSkills?.[c]);
assert.ok(skill && offer.offer!.cards.includes(skill) && !offer.unavailableSkills?.[skill], 'Requested skill must genuinely exist in the saved original offer');
const eligible = new Set(offer.eligibleLeaders.map(l => l.id));
const leader = p.leaders.filter(l => eligible.has(l.id)).sort((a, b) => b.strength - a.strength)[0];
assert.ok(leader);
game = stepPairedChoamSkills(game, { actor, action: { type: 'leaderSkill', event: offer.offer!.event, skill, leader: leader.id } }, actions);
} else game = stepPairedChoamSkills(game, undefined, actions);
}
assert.equal(game.status, 'playing');
return game;
}
export function pairedSkillsPhysicalCards(game: Game): string[] {
return [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? [])].map(c => c.id).sort();
}
export function assertPairedChoamSkillsCustody(game: Game): void {
validateLeaderSkills(game.leaderSkills!, game.players);
const skills = game.leaderSkills!;
assert.deepEqual([...skills.deck, ...Object.values(skills.offers).flatMap(o => o.cards), ...skills.assignments.map(a => a.skill)].sort(), LEADER_SKILL_CARDS.map(c => c.id).sort());
validateNexusCards(game.nexusCards!.cards!, game.players);
const cards = game.nexusCards!.cards!;
assert.deepEqual([...cards.deck, ...cards.discard, ...Object.values(cards.hands).filter((c): c is FactionId => c !== null)].sort(), [...NEXUS_FACTIONS].sort());
assert.deepEqual(pairedSkillsPhysicalCards(game), [...treacheryDeck(['choam']), ...richeseCards()].map(c => c.id).sort());
for (const p of game.players) {
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0), 20, p.faction);
if (p.elites) assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((a, b) => a + b, 0), p.faction === 'fremen' ? 3 : 5);
}
}
function reserveBoard(game: Game, staging: string[]): void {
for (const p of game.players) {
p.reserves += Object.values(p.forces).reduce((a, b) => a + b, 0); p.forces = {};
if (p.elites) {
p.elites.reserves += Object.values(p.elites.forces).reduce((a, b) => a + b, 0); p.elites.forces = {};
}
if (p.advisors) p.advisors = {};
}
staging.push('Conserved original board counters into their own reserves; wallets, turns, phases, skills and custody receipts are unchanged.');
}
export function placePairedSkillsForce(game: Game, actor: string, key: string, count: number, staging?: string[]): void {
const p = pairedSkillsPlayer(game, actor);
assert.ok(Number.isSafeInteger(count) && count >= 0 && p.reserves - (p.elites?.reserves ?? 0) >= count);
p.reserves -= count;
if (count) p.forces[key] = (p.forces[key] ?? 0) + count;
staging?.push(`Conserved ${count} ordinary ${p.faction} counters: native reserves → ${key}; not played movement history.`);
}
function orderUndealtSpice(game: Game, kinds: ('worm' | 'land')[], staging: string[]): void {
const front = kinds.map(kind => {
const index = game.spiceDeck.findIndex(c => kind === 'worm' ? 'worm' in c : 'territory' in c);
assert.ok(index >= 0, 'Only genuine still-undealt Spice Cards can be ordered');
return game.spiceDeck.splice(index, 1)[0];
});
game.spiceDeck.unshift(...front);
staging.push(`Conserved original undealt Spice order: ${kinds.join(', ')}; played Spice Discards remain untouched.`);
}
export function holdPairedSkillsCard(game: Game, actor: string, matches: (card: Card) => boolean, staging: string[] = []): Card {
const p = pairedSkillsPlayer(game, actor), held = p.hand.find(matches);
if (held) return held;
const index = game.deck.findIndex(matches);
assert.ok(index >= 0 && p.hand.length < handLimit(p), 'Required original physical card and native hand capacity must exist');
const card = game.deck.splice(index, 1)[0]; p.hand.push(card);
staging.push(`Conserved original ${card.id}: unused E2 deck → ${p.faction} hand; not a natural deal.`);
return card;
}
/** Native starts/first deals → actual Storm → previous zero-marker shipment →
* actual END Mentat → undealt worm → reciprocal classic alliance → two own
* closing Cunning draws. No skill effects, marker history or wallets are granted. */
export function createPairedChoamNexusSkillsFixture(options: PairedChoamNexusSkillsOptions = {}): PairedChoamNexusSkillsFixture {
const actions: PairedChoamNexusSkillsStep[] = [], staging: string[] = [];
let game = initializePairedChoamNexusSkills(options);
const initial = pairedSkillsSnapshot(game);
game = completePairedChoamSkillsSetup(game, options, actions);
const afterSetup = pairedSkillsSnapshot(game);
const richese = game.players.find(p => p.faction === 'richese')!.id;
const choam = game.players.find(p => p.faction === 'choam')!.id;
const guild = game.players.find(p => p.faction === 'guild')?.id;
const observer = game.players.find(p => p.faction !== 'richese' && p.faction !== 'choam' && p.faction !== 'guild')?.id;
assert.ok(guild && observer, 'Both own Cunning deals require two other real alliance seats including native Guild');
const location = `habbanya_ridge_sietch:${territory('habbanya_ridge_sietch').sectors[0]}`;
game = advancePairedChoamSkillsToPhase(game, 1, actions);
const afterFirstStorm = pairedSkillsSnapshot(game);
orderUndealtSpice(game, ['land', 'land'], staging);
game = advancePairedChoamSkills(game, g => g.phase === 5 && g.active === richese && pairedSkillsClean(g), actions);
reserveBoard(game, staging);
const before = pairedSkillsSnapshot(game), p = pairedSkillsPlayer(game, richese);
const shipmentStep: PairedChoamNexusSkillsStep = { actor: richese, action: { type: 'ship',
noField: p.noField!.tokens.find(t => t.value === 0)!.id, event: p.noFieldEvent,
territory: 'habbanya_ridge_sietch', sector: territory('habbanya_ridge_sietch').sectors[0] } };
game = stepPairedChoamSkills(game, shipmentStep, actions);
game = allowPairedChoamSkills(game, actions);
const firstShipment = { before, step: shipmentStep, after: pairedSkillsSnapshot(game) };
game = revealPairedSkillsRichese(game, richese, actions);
game = advancePairedChoamSkillsToPhase(game, 8, actions);
reserveBoard(game, staging);
placePairedSkillsForce(game, richese, location, 1, staging);
placePairedSkillsForce(game, choam, `arrakeen:${territory('arrakeen').sectors[0]}`, 1, staging);
let beforeFirstMentat: Game | undefined, firstMentatStep: PairedChoamNexusSkillsStep | undefined;
while (game.turn === 1) {
const previous: Game = pairedSkillsSnapshot(game);
const next: PairedChoamNexusSkillsStep | null = nextPairedChoamSkillsStep(game); assert.ok(next);
game = stepPairedChoamSkills(game, next, actions);
if (game.turn === 2) { beforeFirstMentat = previous; firstMentatStep = next; }
}
assert.ok(beforeFirstMentat && firstMentatStep);
const afterFirstMentat = pairedSkillsSnapshot(game);
game = advancePairedChoamSkillsToPhase(game, 1, actions);
orderUndealtSpice(game, ['worm', 'land', 'land'], staging);
game = advancePairedChoamSkills(game, g => g.nexus === true && !g.spiceWindow && !g.spiceResolution && pairedSkillsClean(g), actions);
const beforeAlliance = pairedSkillsSnapshot(game), allianceActions: PairedChoamNexusSkillsStep[] = [
{ actor: guild, action: { type: 'alliance', target: observer } },
{ actor: observer, action: { type: 'alliance', target: guild } },
];
for (const next of allianceActions) game = stepPairedChoamSkills(game, next, actions);
game = advancePairedChoamSkills(game, g => g.nexusCards?.phase?.stage === 'drawing' && pairedSkillsClean(g), actions);
const beforeClosingDraw = pairedSkillsSnapshot(game), closingDraws: PairedChoamNexusSkillsFixture['closingDraws'] = [];
for (const [actor, card] of [[richese, 'richese'], [choam, 'choam']] as const) {
const cards = game.nexusCards!.cards!, index = cards.deck.indexOf(card); assert.ok(index >= 0);
cards.deck.unshift(cards.deck.splice(index, 1)[0]);
staging.push(`Conserved original unused ${card} Nexus singleton placed first before its actual closing draw.`);
const drawBefore = pairedSkillsSnapshot(game);
const next: PairedChoamNexusSkillsStep = { actor, action: { type: 'nexusCardChoice', turn: game.turn, card: null, choice: 'draw', ownRedraws: 0 } };
game = stepPairedChoamSkills(game, next, actions);
closingDraws.push({ before: drawBefore, step: next, after: pairedSkillsSnapshot(game) });
}
game = advancePairedChoamSkillsToPhase(game, 5, actions);
reserveBoard(game, staging);
const movementStart = pairedSkillsSnapshot(game);
game = advancePairedChoamSkills(game, g => g.phase === 5 && g.active === richese && pairedSkillsClean(g), actions);
if (options.reserveCap !== undefined) {
const p = pairedSkillsPlayer(game, richese);
assert.ok(Number.isSafeInteger(options.reserveCap) && options.reserveCap >= 0 && options.reserveCap <= p.reserves);
placePairedSkillsForce(game, richese, 'polar_sink:0', p.reserves - options.reserveCap, staging);
}
assertPairedChoamSkillsCustody(game);
const trainers = { richese: game.leaderSkills!.assignments.find(a => a.owner === richese)!.leader,
choam: game.leaderSkills!.assignments.find(a => a.owner === choam)!.leader };
return { initial, afterSetup, afterFirstStorm, firstShipment, beforeFirstMentat, firstMentatStep, afterFirstMentat,
beforeAlliance, allianceActions, beforeClosingDraw, closingDraws, movementStart, game, richese, choam, guild, observer, location, trainers, actions, staging };
}
/** Exact failure-before API for the parent's real signed-pair validator integration.
* This is the original action shape, with no fabricated skill proof/companion. */
export function pairedSkillsRicheseCunningRequest(f: PairedChoamNexusSkillsFixture, game = f.game): Action {
const p = pairedSkillsPlayer(game, f.richese), offer = viewGame(game, f.richese).nexusRicheseCunning;
assert.ok(offer && !offer.blocked);
return { type: 'ship', noField: p.noField!.tokens.find(t => t.value === 3)!.id,
revealedToken: p.noField!.tokens.find(t => t.value === 5)!.id, nexus: offer.event, event: p.noFieldEvent,
territory: 'habbanya_ridge_sietch', sector: territory('habbanya_ridge_sietch').sectors[0], allyPayment: 0 };
}
export function allowPairedChoamSkills(game: Game, actions?: PairedChoamNexusSkillsStep[]): Game {
return advancePairedChoamSkills(game, pairedSkillsClean, actions);
}
export function revealPairedSkillsRichese(game: Game, actor: string, actions?: PairedChoamNexusSkillsStep[]): Game {
const p = pairedSkillsPlayer(game, actor); assert.ok(p.noField!.deployed);
return stepPairedChoamSkills(game, { actor, action: { type: 'revealNoField', event: p.noFieldEvent, token: p.noField!.deployed.tokenId } }, actions);
}
export function openPairedSkillsBattle(game: Game, owner: string, opponent: string,
options: { territory?: string; band?: 'normal' | 'skilled'; actions?: PairedChoamNexusSkillsStep[] } = {}): Game {
const actions = options.actions;
game = advancePairedChoamSkillsToPhase(game, 6, actions);
assert.equal(game.phase, 6, 'The genuine battle needs conserved opposing physical forces');
assert.ok(game.active === owner || game.active === opponent);
game = stepPairedChoamSkills(game, { actor: game.active!, action: { type: 'chooseBattle',
territory: options.territory ?? 'habbanya_ridge_sietch', target: game.active === owner ? opponent : owner } }, actions);
for (let n = 0; n < 100; n++) {
if (pairedSkillsClean(game) && game.battle && (!game.battle.preLeader || game.battle.preLeader.closed) && !game.battle.preparation) return game;
const decision = game.decision;
const next: PairedChoamNexusSkillsStep | null = decision?.kind === 'leaderSkillVisibility'
? { actor: decision.player, action: { type: 'leaderSkillVisibility', event: decision.event,
hide: decision.player === owner && options.band === 'skilled' } }
: nextPairedChoamSkillsStep(game);
game = stepPairedChoamSkills(game, next, actions);
}
throw Error('The original battle did not reach its actual human plan boundary');
}
export function pairedSkillsBattlePlans(game: Game, owner: string, opponent: string, dial = 2,
trained = true): PairedChoamNexusSkillsStep[] {
const p = pairedSkillsPlayer(game, owner), trainedLeader = game.leaderSkills!.assignments.find(a => a.owner === owner)!.leader;
const leader = trained ? p.leaders.find(l => l.id === trainedLeader && !l.dead)
: p.leaders.filter(l => !l.dead && l.id !== trainedLeader).sort((a, b) => b.strength - a.strength)[0];
const targetTrainer = game.leaderSkills!.assignments.find(a => a.owner === opponent)!.leader;
const target = pairedSkillsPlayer(game, opponent).leaders.filter(l => !l.dead && l.id !== targetTrainer).sort((a, b) => a.strength - b.strength)[0];
assert.ok(leader && target && leader.strength + dial > target.strength);
return [
{ actor: owner, action: { type: 'battlePlan', leader: leader.id, dial, support: game.advanced ? dial : 0, weapon: null, defense: null } },
{ actor: opponent, action: { type: 'battlePlan', leader: target.id, dial: 0, support: 0, weapon: null, defense: null } },
];
}
export function finishPairedSkillsBattle(game: Game, actions?: PairedChoamNexusSkillsStep[]): Game {
return advancePairedChoamSkills(game, g => !g.battle && pairedSkillsClean(g) && !g.pendingTreacheryDiscard, actions);
}
/** Original skill posture and native bank/support legs on the real revealed source. */
export function quotePairedSkillsBattle(game: Game) {
const battle = game.battle; assert.ok(battle?.revealed);
const side = (actor: string): ResolutionCombatant => {
const p = pairedSkillsPlayer(game, actor), view = viewGame(game, actor).battle!;
assert.ok(view.ownForces);
return { id: actor, faction: p.faction, ally: p.ally, spice: p.spice, hand: p.hand,
plan: battle.plans[actor], leader: p.leaders.find(l => l.id === battle.plans[actor].leader),
forces: view.ownForces, stronghold: view.strongholdEffects[actor],
leaderSkills: game.leaderSkills!.assignments.filter(a => a.owner === actor).map(a => ({
skill: a.skill, leader: a.leader, faceUp: !battle.leaderSkillHidden?.[actor], captured: false,
})) };
};
return quoteBattleResolution({ advanced: game.advanced, turn: game.turn, territory: battle.territory,
aggressor: viewGame(game, game.host).battle!.aggressor, attacker: side(battle.attacker), defender: side(battle.defender),
voters: viewGame(game, game.host).battle!.traitorVoters.map(actor => ({ id: actor,
beneficiary: [battle.attacker, battle.defender].includes(actor) ? actor : pairedSkillsPlayer(game, actor).ally!,
called: battle.traitorCalls[actor] ?? false, traitors: pairedSkillsPlayer(game, actor).traitors })),
participants: game.players,
physicalCards: [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand), ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? [])],
pendingAuditorPresent: !!game.pendingAuditor, pendingRetentionPresent: false });
}
export function pairedSkillsChoamCunningRequest(game: Game, actor: string, card: string): Action {
const offer = viewGame(game, actor).choamWorthless!.plays.find(p => p.source === 'nexus' && p.card.id === card && p.effect === 'kulon');
assert.ok(offer && offer.source === 'nexus' && !offer.blocked);
return { type: 'card', mode: 'choam', card, effect: 'kulon', nexus: offer.event };
}
/** Ordinary three-territory route, not No-Field or a special movement-card route.
* Kulon plus the original Planetologist range is tested through native actions. */
export function stagePairedSkillsPlanetologistRoute(game: Game, actor: string, staging?: string[]): { from: string; to: string; action: Action } {
const keys = TERRITORIES.filter(t => t.type === 'sand').flatMap(t => t.sectors.map(s => `${t.id}:${s}`));
let to: string | undefined;
const from = keys.find(origin => {
if (splitLocation(origin).sector === game.storm) return false;
to = keys.find(target => splitLocation(target).sector !== game.storm &&
gameDistance(game, origin, target, key => splitLocation(key).sector === game.storm) === 3);
return !!to;
});
assert.ok(from && to, 'A clear ordinary three-territory Planetologist route must exist');
placePairedSkillsForce(game, actor, from, 1, staging);
return { from, to, action: { type: 'move', planetologist: 'range', forces: { [from]: 1 }, ...splitLocation(to) } };
}
+383
View File
@@ -0,0 +1,383 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, handLimit, initializeLeaderSkillsGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game, type Player,
} from '../game/engine';
import { territory } from '../game/board';
import type { Card } from '../game/cards';
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
import { pairedNexusLeaderSkillsProfile } from '../game/leader-skill-profile';
import { quoteBattleResolution, type ResolutionCombatant } from '../game/battle-resolution-quote';
import { traitorDeck } from '../game/traitors';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
export type PairedIxNexusSkillsStep = { actor: string; action: Action };
export type PairedIxNexusSkillsOptions = {
/** Genuine fresh lobby or original turn-one/phase-zero setup with zero assignments.
* Existing first hands, offers, actor IDs and stock are authoritative. */
initial?: Game;
advanced?: boolean;
program?: 'suboids' | 'faceDance';
skill?: LeaderSkillId;
band?: 'normal' | 'skilled';
dial?: number;
support?: number;
secondBattle?: boolean;
};
export type PairedIxNexusSkillsFixture = {
initial: Game;
offered: Game;
afterSetup: Game;
afterFirstStorm: Game;
beforeFirstMentat: Game;
firstMentatStep: PairedIxNexusSkillsStep;
afterFirstMentat: Game;
beforeAlliance: Game;
allianceActions: PairedIxNexusSkillsStep[];
beforeNexusDraw: Game;
drawActions: PairedIxNexusSkillsStep[];
afterNexusDraw: Game;
beforeBattle: Game;
battleStep: PairedIxNexusSkillsStep;
game: Game;
ixians: string;
tleilaxu: string;
opponent: string;
partners: [string, string];
trainer: string;
skill: LeaderSkillId;
band: 'normal' | 'skilled';
program: 'suboids' | 'faceDance';
kind: 'arrakeen' | 'sietch_tabr';
location: string;
replacementSource: string;
secondLocation?: string;
planActions: PairedIxNexusSkillsStep[];
actions: PairedIxNexusSkillsStep[];
staging: string[];
};
export function pairedIxNexusSkillsPlayer(game: Game, actor: string): Player {
const player = game.players.find(p => p.id === actor);
assert.ok(player, `Missing original E1 seat ${actor}`);
return player;
}
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
/** Control native shuffles only, never fabricate dealt cards. Non-word entropy
* forwards through the bound original method and always returns its input array. */
function entropy<T>(operation: () => T, rolls: number[] = []): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const original = crypto.getRandomValues.bind(crypto);
let cursor = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array) {
for (let i = 0; i < array.length; i++) array[i] = rolls[cursor++] ?? 0xffffffff;
} else Reflect.apply(original, crypto, [array]);
return array;
};
try { return operation(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
function shuffleRolls(source: string[], desired: string[]): number[] {
const working = [...source], rolls: number[] = [];
assert.deepEqual([...source].sort(), [...desired].sort());
for (let i = working.length - 1; i > 0; i--) {
const from = working.indexOf(desired[i]); assert.ok(from >= 0 && from <= i);
rolls.push(Math.floor((from + 0.5) * 0x100000000 / (i + 1)));
[working[i], working[from]] = [working[from], working[i]];
}
return rolls;
}
function skillRolls(selected: LeaderSkillId[]): number[] {
const source = LEADER_SKILL_CARDS.map(c => c.id);
const remainder = source.filter(c => !selected.includes(c));
const desired = selected.flatMap(c => [c, remainder.shift()!]);
return shuffleRolls(source, [...desired, ...remainder]);
}
/** Original default continuation declines native Face Dance; callers can stop
* at its real window before choosing a matching-stock reveal. Private plans remain explicit. */
export function nextPairedIxNexusSkillsStep(game: Game): PairedIxNexusSkillsStep | null {
if (clean(game) && game.nexusCards?.phase?.stage === 'drawing') {
const phase = game.nexusCards.phase, actor = phase.eligible.find(id => !phase.done.includes(id));
assert.ok(actor);
return { actor, action: { type: 'nexusCardChoice', turn: game.turn,
card: game.nexusCards.cards!.hands[actor] ?? null, choice: 'keep', ownRedraws: 0 } };
}
if (!game.phaseOpening && !game.response) {
const d = game.decision;
if (d?.kind === 'faceDance') return { actor: d.player, action: { type: 'decision', reveal: false } };
if (d?.kind === 'leaderSkillVisibility') return { actor: d.player,
action: { type: 'leaderSkillVisibility', event: d.event, hide: false } };
if (d?.kind === 'mentatQuestion') return { actor: d.player,
action: { type: 'decision', event: d.event, decline: true } };
if (d?.kind === 'rihani') return { actor: d.player,
action: { type: 'decision', event: d.event, draw: false } };
if (d?.kind === 'sukRescue') {
const best = d.options.map((o, choice) => ({ choice, count: o.normal + o.elite, kept: !!o.kept }))
.sort((a, b) => b.count - a.count || Number(b.kept) - Number(a.kept))[0];
return { actor: d.player, action: { type: 'decision', event: d.event, choice: best.choice } };
}
}
return nextStrongholdFactionsNativeStep(game);
}
export function stepPairedIxNexusSkills(game: Game, step = nextPairedIxNexusSkillsStep(game)): Game {
assert.ok(step, 'Original human plan or matching-stock Face Dance is required');
return entropy(() => applyAction(game, step.actor, step.action));
}
export function advancePairedIxNexusSkills(state: Game, until: (game: Game) => boolean,
actions: PairedIxNexusSkillsStep[] = []): Game {
let game = structuredClone(state);
for (let i = 0; i < 2400; i++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const step = nextPairedIxNexusSkillsStep(game); assert.ok(step, 'Native explicit human choice before requested boundary');
actions.push(structuredClone(step)); game = stepPairedIxNexusSkills(game, step);
}
throw Error('Paired E1 Nexus/Skills continuation did not reach its native boundary');
}
function reserveBoard(game: Game): void {
for (const p of game.players) {
p.reserves += Object.values(p.forces).reduce((sum, n) => sum + n, 0); p.forces = {};
if (p.elites) {
p.elites.reserves += Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0); p.elites.forces = {};
}
if (p.advisors) p.advisors = {};
}
}
function place(game: Game, actor: string, key: string, total: number, elite = 0): void {
const p = pairedIxNexusSkillsPlayer(game, actor);
assert.ok(total >= elite && p.reserves - (p.elites?.reserves ?? 0) >= total - elite && (p.elites?.reserves ?? 0) >= elite);
p.reserves -= total; p.forces[key] = (p.forces[key] ?? 0) + total;
if (elite) { p.elites!.reserves -= elite; p.elites!.forces[key] = (p.elites!.forces[key] ?? 0) + elite; }
}
function hold(game: Game, actor: string, kind: Card['kind'], staging: string[]): string {
const p = pairedIxNexusSkillsPlayer(game, actor), held = p.hand.find(c => c.kind === kind);
if (held) return held.id;
const index = game.deck.findIndex(c => c.kind === kind);
const donor = index < 0 ? game.players.find(other => other.id !== actor && other.hand.some(c => c.kind === kind)) : undefined;
assert.ok(index >= 0 || donor, 'Required original physical card has a deck/hand custodian');
assert.ok(p.hand.length < handLimit(p), 'Preserve actual starting hands and require real capacity');
const card = index >= 0 ? game.deck.splice(index, 1)[0] : donor!.hand.splice(donor!.hand.findIndex(c => c.kind === kind), 1)[0];
p.hand.push(card); staging.push(`Conserved ${card.id}: ${donor?.id ?? 'original remaining deck'} → ${actor} hand after native deals`);
return card.id;
}
function orderSpice(game: Game, kinds: ('land' | 'worm')[], staging: string[]): void {
const remaining = [...game.spiceDeck];
const ordered = kinds.map(kind => {
const at = remaining.findIndex(c => kind === 'land' ? 'territory' in c : 'worm' in c && !c.greatMaker && !c.suppressed);
assert.ok(at >= 0); return remaining.splice(at, 1)[0];
});
game.spiceDeck = [...ordered, ...remaining];
staging.push(`Conserved original undealt Spice order: ${kinds.join(', ')}; not natural shuffle history`);
}
/** Original 47/14/12 native setup → Storm → real Mentat → conserved worm →
* real alliance → real closing native draws. Board/card relocation is explicit;
* wallets, phases, turns, effects, stock and sealed plans are never assigned. */
export function createPairedIxNexusSkillsFixture(options: PairedIxNexusSkillsOptions = {}): PairedIxNexusSkillsFixture {
const advanced = options.advanced ?? options.initial?.advanced ?? true;
const program = options.program ?? 'suboids', band = options.band ?? 'skilled';
let game = options.initial ? structuredClone(options.initial)
: createGame('PAIREDIXNEXUSSKILLS', newPlayer('ixians', 'Ixians', 'ixians'), advanced, ['ix']);
if (!options.initial) for (const faction of ['tleilaxu', 'guild', 'emperor'] as const)
joinGame(game, newPlayer(faction, faction, faction));
assert.equal(game.advanced, advanced); assert.deepEqual(game.expansions, ['ix']);
assert.ok(!game.techTokens && !game.strongholdCards && !game.homeworlds);
assert.ok(game.players.length >= 4 && game.players.length <= 6,
'Both native Cunning draws require two separate classic alliance partners; use four through six original seats');
const ixians = game.players.find(p => p.faction === 'ixians')?.id;
const tleilaxu = game.players.find(p => p.faction === 'tleilaxu')?.id;
assert.ok(ixians && tleilaxu);
const classics = game.players.filter(p => p.id !== ixians && p.id !== tleilaxu);
const opponent = classics.find(p => p.faction === 'guild')?.id ?? classics[0].id;
const partners: [string, string] = [classics[0].id, classics[1].id];
const actions: PairedIxNexusSkillsStep[] = [], staging: string[] = [];
const requested = options.skill ?? 'suk-graduate';
const passives: LeaderSkillId[] = (['sandmaster', 'smuggler', 'rihani-decipherer', 'prana-bindu-adept', 'killer-medic', 'master-of-assassins'] as LeaderSkillId[])
.filter(skill => skill !== requested);
const selected = game.players.map(p => p.id === ixians ? requested : passives.shift()!);
const supplied = !!options.initial;
const initial = structuredClone(game);
if (game.status === 'lobby') {
assert.ok(!game.leaderSkills, 'Original all14 custody cannot be replaced');
game.nexusCards ??= { cards: null, phase: null };
assert.deepEqual(game.nexusCards, { cards: null, phase: null });
for (const p of game.players) if (!p.ready) {
const step: PairedIxNexusSkillsStep = { actor: p.id, action: { type: 'ready' } };
actions.push(step); game = stepPairedIxNexusSkills(game, step);
}
game = entropy(() => initializeLeaderSkillsGameForAudit(game), skillRolls(selected));
staging.push('Scoped original all14 shuffle selects native offers before starting Treachery deals; no offers are replaced');
}
assert.ok(pairedNexusLeaderSkillsProfile(game));
assert.equal(game.status, 'setup'); assert.equal(game.turn, 1); assert.equal(game.phase, 0);
assert.equal(game.leaderSkills!.assignments.length, 0);
let offered = structuredClone(game), trainer = '';
for (let i = 0; game.status === 'setup' && i < 240; i++) {
let step: PairedIxNexusSkillsStep;
if (game.setupStage === 'leaderSkills') {
offered = trainer ? offered : structuredClone(game);
const actor: string = Object.keys(game.leaderSkills!.offers)[0];
const offer: NonNullable<Game['leaderSkills']>['offers'][string] = game.leaderSkills!.offers[actor];
const projection = viewGame(game, actor).leaderSkills!;
const legal: LeaderSkillId[] = offer.cards.filter(c => !projection.unavailableSkills?.[c]);
const desired: LeaderSkillId = selected[game.players.findIndex(p => p.id === actor)];
const skill: LeaderSkillId | undefined = actor === ixians && options.skill ? legal.find(c => c === options.skill)
: !supplied && legal.includes(desired) ? desired : legal[0];
assert.ok(skill, 'Explicit requested skill must genuinely exist in the original saved offer');
const eligible = new Set(projection.eligibleLeaders.map(l => l.id));
const leader = pairedIxNexusSkillsPlayer(game, actor).leaders.filter(l => eligible.has(l.id)).sort((a, b) => b.strength - a.strength)[0];
assert.ok(leader);
if (actor === ixians) trainer = leader.id;
step = { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: leader.id } };
} else {
const native = nextPairedIxNexusSkillsStep(game); assert.ok(native); step = native;
if (!supplied && game.setupStage === 'traitors' && step.action.type === 'traitor') {
const choices = pairedIxNexusSkillsPlayer(game, step.actor).traitorChoices;
const card = choices.find(id => id !== trainer); assert.ok(card); step.action.leader = card;
}
}
actions.push(structuredClone(step));
if (!supplied && game.setupStage === 'forces') {
const held = new Set(game.players.flatMap(p => p.traitors));
const source = traitorDeck(game.players, true).filter(id => !held.has(id));
assert.ok(source.includes(trainer), 'Original trained Ix identity must remain undealt');
game = entropy(() => applyAction(game, step.actor, step.action), shuffleRolls(source, [trainer, ...source.filter(id => id !== trainer)]));
} else game = supplied ? applyAction(game, step.actor, step.action) : stepPairedIxNexusSkills(game, step);
}
assert.equal(game.status, 'playing');
const afterSetup = structuredClone(game);
const assignment = game.leaderSkills!.assignments.find(a => a.owner === ixians)!;
trainer = assignment.leader; const skill = assignment.skill;
game = advancePairedIxNexusSkills(game, g => g.phase === 1 && clean(g), actions);
const afterFirstStorm = structuredClone(game);
orderSpice(game, ['land', 'land'], staging);
game = advancePairedIxNexusSkills(game, g => g.phase === 5 && clean(g), actions);
reserveBoard(game); staging.push('After native starting placements: conserved board counters returned to original reserves before first movement completion');
game = advancePairedIxNexusSkills(game, g => g.phase === 8 && clean(g), actions);
let beforeFirstMentat!: Game, firstMentatStep!: PairedIxNexusSkillsStep;
while (game.turn === 1) {
const step = nextPairedIxNexusSkillsStep(game); assert.ok(step);
const before = structuredClone(game); actions.push(structuredClone(step)); game = stepPairedIxNexusSkills(game, step);
if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = step; }
}
const afterFirstMentat = structuredClone(game);
game = advancePairedIxNexusSkills(game, g => g.phase === 1 && clean(g), actions);
orderSpice(game, ['worm', 'land', 'land'], staging);
game = advancePairedIxNexusSkills(game, g => !!g.nexus && !g.spiceWindow && !g.spiceResolution && clean(g), actions);
const beforeAlliance = structuredClone(game);
const allianceActions: PairedIxNexusSkillsStep[] = partners.map((actor, i) => ({ actor, action: { type: 'alliance', target: partners[1 - i] } }));
for (const step of allianceActions) { actions.push(step); game = stepPairedIxNexusSkills(game, step); }
game = advancePairedIxNexusSkills(game, g => g.nexusCards?.phase?.stage === 'drawing', actions);
const cards = game.nexusCards!.cards!;
assert.ok(cards.deck.includes('ixians') && cards.deck.includes('tleilaxu'));
cards.deck = ['ixians', 'tleilaxu', ...cards.deck.filter(c => c !== 'ixians' && c !== 'tleilaxu')];
staging.push('Order conserved unused Ix/Tleilaxu Nexus singletons; only real closing draws grant the two native hands');
const beforeNexusDraw = structuredClone(game);
const drawActions: PairedIxNexusSkillsStep[] = [ixians, tleilaxu].map(actor => ({ actor,
action: { type: 'nexusCardChoice', turn: game.turn, card: cards.hands[actor], choice: 'draw', ownRedraws: 0 } }));
for (const step of drawActions) { actions.push(step); game = stepPairedIxNexusSkills(game, step); }
game = advancePairedIxNexusSkills(game, g => g.nexusCards?.phase?.stage !== 'drawing', actions);
const afterNexusDraw = structuredClone(game);
game = advancePairedIxNexusSkills(game, g => g.phase === 5 && clean(g), actions);
reserveBoard(game);
const kind = program === 'faceDance' ? 'sietch_tabr' : 'arrakeen';
const location = `${kind}:${territory(kind).sectors[0]}`, replacementSource = `arrakeen:${territory('arrakeen').sectors[0]}`;
place(game, ixians, location, 6, 3); place(game, opponent, location, 6);
if (program === 'faceDance') place(game, tleilaxu, replacementSource, 3);
const secondLocation = options.secondBattle ? `carthag:${territory('carthag').sectors[0]}` : undefined;
if (secondLocation) { place(game, ixians, secondLocation, 3); place(game, opponent, secondLocation, 2); }
staging.push('Before native movement completion: conserved six Ix counters (three Cyborg/three Suboid), six ordinary opponents and optional physical replacement/second-battle sources; no phase, wallet or stock mutation');
const ix = pairedIxNexusSkillsPlayer(game, ixians), stock = pairedIxNexusSkillsPlayer(game, tleilaxu).faceDancers!;
const untrained = (p: Player) => p.leaders.filter(l => !l.dead && !l.usedAt && !l.gholaBy && !l.capturedBy &&
!game.leaderSkills!.assignments.some(a => a.leader === l.id));
const leader = band === 'skilled' ? ix.leaders.find(l => l.id === trainer)! : untrained(ix)
.filter(l => program !== 'faceDance' || stock.some(c => c.leader === l.id && !c.revealed)).sort((a, b) => b.strength - a.strength)[0];
assert.ok(leader && (program !== 'faceDance' || stock.some(c => c.leader === leader.id && !c.revealed)),
'Infeasible original stock: this band requires a matching living original Ix disc; no stock is fabricated or exchanged');
const enemy = untrained(pairedIxNexusSkillsPlayer(game, opponent)).sort((a, b) => a.strength - b.strength)[0]; assert.ok(enemy);
const weapon = skill === 'warmaster' ? hold(game, ixians, 'worthless', staging)
: program === 'faceDance' ? hold(game, ixians, 'projectile', staging) : null;
const defense = program === 'faceDance' ? hold(game, ixians, 'shield', staging) : null;
game = advancePairedIxNexusSkills(game, g => g.phase === 6 && clean(g), actions);
const beforeBattle = structuredClone(game), actor = game.active!; assert.ok(actor === ixians || actor === opponent);
const battleStep: PairedIxNexusSkillsStep = { actor, action: { type: 'chooseBattle', territory: kind, target: actor === ixians ? opponent : ixians } };
actions.push(battleStep); game = stepPairedIxNexusSkills(game, battleStep);
const planActions: PairedIxNexusSkillsStep[] = [
{ actor: ixians, action: { type: 'battlePlan', leader: leader.id, dial: options.dial ?? (advanced ? 4 : 6),
support: advanced ? options.support ?? 1 : 0, weapon, defense } },
{ actor: opponent, action: { type: 'battlePlan', leader: enemy.id, dial: 0, support: 0, weapon: null, defense: null } },
];
return { initial, offered, afterSetup, afterFirstStorm, beforeFirstMentat, firstMentatStep, afterFirstMentat,
beforeAlliance, allianceActions, beforeNexusDraw, drawActions, afterNexusDraw, beforeBattle, battleStep,
game, ixians, tleilaxu, opponent, partners, trainer, skill, band, program, kind, location, replacementSource,
secondLocation, planActions, actions, staging };
}
export function preparePairedIxNexusSkillsBattle(f: PairedIxNexusSkillsFixture, state = f.game): Game {
let game = structuredClone(state);
for (let i = 0; i < 160; i++) {
const step = nextPairedIxNexusSkillsStep(game);
if (!step) { assert.ok(game.battle && !game.battle.revealed); return game; }
if (step.action.type === 'leaderSkillVisibility') step.action.hide = step.actor === f.ixians && f.band === 'skilled';
game = stepPairedIxNexusSkills(game, step);
}
throw Error('Paired E1 native skill posture did not reach private plans');
}
export function beginPairedIxNexusSkillsCunning(f: PairedIxNexusSkillsFixture, state = preparePairedIxNexusSkillsBattle(f)): Game {
const offer = viewGame(state, f.ixians).nexusSuboids?.offer;
assert.ok(offer); assert.equal(offer.blocked, null);
return stepPairedIxNexusSkills(state, { actor: f.ixians, action: { type: 'nexusSuboids', event: offer.event } });
}
export function revealPairedIxNexusSkillsBattle(f: PairedIxNexusSkillsFixture,
options: { state?: Game; cunning?: boolean; plans?: PairedIxNexusSkillsStep[] } = {}): Game {
let game = preparePairedIxNexusSkillsBattle(f, options.state ?? f.game);
if (options.cunning) game = advancePairedIxNexusSkills(beginPairedIxNexusSkillsCunning(f, game), clean);
for (const step of options.plans ?? f.planActions) {
game = stepPairedIxNexusSkills(game, step);
while (game.response || game.phaseOpening || game.decision?.kind === 'fullPlanOffer') game = stepPairedIxNexusSkills(game);
}
assert.ok(game.battle?.revealed); return game;
}
export function finishPairedIxNexusSkillsBattle(state: Game,
stop: 'losses' | 'rescue' | 'substitution' | 'cards' | 'faceDance' | 'complete' = 'complete'): Game {
const kinds = { losses: 'battleLosses', rescue: 'sukRescue', substitution: 'ixSubstitution', cards: 'battleCards', faceDance: 'faceDance', complete: '' };
return advancePairedIxNexusSkills(state, g => g.decision?.kind === kinds[stop] || !g.battle && clean(g) && !g.pendingTreacheryDiscard);
}
export function finishPairedIxNexusSkillsTurn(state: Game): Game {
return advancePairedIxNexusSkills(state, g => g.turn > state.turn);
}
export function choosePairedIxNexusSkillsSecondBattle(f: PairedIxNexusSkillsFixture, state: Game): Game {
assert.ok(f.secondLocation);
let game = advancePairedIxNexusSkills(state, g => g.phase === 6 && clean(g) && !!g.active);
const actor = game.active!; assert.ok(actor === f.ixians || actor === f.opponent);
game = stepPairedIxNexusSkills(game, { actor, action: { type: 'chooseBattle', territory: 'carthag', target: actor === f.ixians ? f.opponent : f.ixians } });
return preparePairedIxNexusSkillsBattle(f, game);
}
export function preparePairedIxNexusSkillsExpiryBattle(f: PairedIxNexusSkillsFixture, state: Game): Game {
let game = advancePairedIxNexusSkills(state, g => g.phase === 5 && clean(g));
reserveBoard(game); place(game, f.ixians, f.location, 3); place(game, f.tleilaxu, f.location, 2);
game = advancePairedIxNexusSkills(game, g => g.phase === 6 && clean(g));
const actor = game.active!; assert.ok(actor === f.ixians || actor === f.tleilaxu);
game = stepPairedIxNexusSkills(game, { actor, action: { type: 'chooseBattle', territory: f.kind, target: actor === f.ixians ? f.tleilaxu : f.ixians } });
return preparePairedIxNexusSkillsBattle(f, game);
}
/** Actual skill posture and actual native Cunning force flags, not inferred cap arithmetic. */
export function quotePairedIxNexusSkillsBattle(game: Game) {
const battle = game.battle; assert.ok(battle?.revealed);
const side = (actor: string): ResolutionCombatant => {
const p = pairedIxNexusSkillsPlayer(game, actor), view = viewGame(game, actor).battle!; assert.ok(view.ownForces);
return { id: actor, faction: p.faction, ally: p.ally, spice: p.spice, hand: p.hand, plan: battle.plans[actor],
leader: p.leaders.find(l => l.id === battle.plans[actor].leader), forces: view.ownForces,
stronghold: view.strongholdEffects[actor], leaderSkills: game.leaderSkills!.assignments.filter(a => a.owner === actor)
.map(a => ({ skill: a.skill, leader: a.leader, faceUp: !battle.leaderSkillHidden?.[actor], captured: false })) };
};
return quoteBattleResolution({ advanced: game.advanced, turn: game.turn, territory: battle.territory,
aggressor: viewGame(game, game.host).battle!.aggressor, attacker: side(battle.attacker), defender: side(battle.defender),
voters: viewGame(game, game.host).battle!.traitorVoters.map(actor => ({ id: actor,
beneficiary: [battle.attacker, battle.defender].includes(actor) ? actor : pairedIxNexusSkillsPlayer(game, actor).ally!,
called: battle.traitorCalls[actor] ?? false, traitors: pairedIxNexusSkillsPlayer(game, actor).traitors })),
participants: game.players, physicalCards: [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand)],
pendingAuditorPresent: false, pendingRetentionPresent: false });
}
@@ -0,0 +1,301 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { presenceAt } from '../game/force-presence';
import { territory } from '../game/board';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
import { reserveShipmentCost } from '../game/shipment-price';
import { isPlanetologistBattleSpecialCard } from '../game/leader-skill-combat';
import {
advancePairedChoamSkills, advancePairedChoamSkillsToPhase, allowPairedChoamSkills,
assertPairedChoamSkillsCustody as custody, completePairedChoamSkillsSetup,
createPairedChoamNexusSkillsFixture, finishPairedSkillsBattle, holdPairedSkillsCard,
initializePairedChoamNexusSkills, openPairedSkillsBattle, pairedSkillsBattlePlans,
pairedSkillsChoamCunningRequest, pairedSkillsClean, pairedSkillsPhysicalCards,
pairedSkillsPlayer as player, pairedSkillsRicheseCunningRequest, pairedSkillsSnapshot,
placePairedSkillsForce, revealPairedSkillsRichese, stagePairedSkillsPlanetologistRoute,
quotePairedSkillsBattle,
stepPairedChoamSkills, type PairedChoamNexusSkillsFixture,
} from './fixture-paired-choam-nexus-skills';
function reject(game: Game, actor: string, action: Action): void {
const before = pairedSkillsSnapshot(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before, 'Illegal composition preserves original spice, cards, markers, forces and skill custody');
}
function assertPair(f: PairedChoamNexusSkillsFixture, shipped: Game, faceup: number): void {
const request = pairedSkillsRicheseCunningRequest(f), before = player(f.game, f.richese), after = player(shipped, f.richese);
assert.equal(after.spice, before.spice - 1);
assert.equal(player(shipped, f.guild).spice, player(f.game, f.guild).spice + 1, 'One original one-marker Stronghold tariff, not eight counter invoices');
assert.equal(after.reserves, before.reserves - faceup);
assert.equal(after.forces[f.location] ?? 0, faceup);
assert.equal(after.noField!.deployed!.tokenId, request.noField);
assert.equal(after.noField!.lastShipped, request.noField);
assert.equal(presenceAt(after, 'habbanya_ridge_sietch'), faceup + 1, 'The second group is one concealed marker, not extra ordinary force identity');
assert.equal(shipped.nexusCards!.cards!.hands[f.richese], null);
assert.equal(shipped.nexusRicheseCunningLast!.stage, 'shipped');
assert.equal(after.shipped, true);
assert.deepEqual(shipped.leaderSkills!.assignments, f.game.leaderSkills!.assignments, 'Cunning neither borrows nor fabricates a companion skill');
assert.deepEqual(pairedSkillsPhysicalCards(shipped), pairedSkillsPhysicalCards(f.afterSetup));
custody(shipped);
}
for (const advanced of [false, true]) void test(`paired E2 ${advanced ? 'Advanced' : 'Basic'}: actual previous marker, all14 skill assignments, worm/alliance/closing draws and single physical pair invoice`, () => {
const f = createPairedChoamNexusSkillsFixture({ advanced });
assert.equal(f.initial.richeseCache!.length, 10);
assert.equal(pairedSkillsPhysicalCards(f.initial).length, 45, 'Original CHOAM35 plus separate Richese10, not canonical47 or a mixed deck');
for (const p of f.initial.players) assert.equal(p.hand.length, 1, 'Native starting cards are dealt before original skill offers');
assert.equal(f.afterSetup.leaderSkills!.assignments.length, 4);
assert.equal(f.beforeFirstMentat.turn, 1);
assert.equal(f.afterFirstMentat.turn, 2);
assert.equal(f.beforeAlliance.nexus, true);
assert.equal(player(f.beforeClosingDraw, f.guild).ally, f.observer);
assert.equal(player(f.beforeClosingDraw, f.observer).ally, f.guild);
for (const draw of f.closingDraws) {
assert.equal(draw.before.nexusCards!.cards!.hands[draw.step.actor], null);
assert.equal(draw.after.nexusCards!.cards!.hands[draw.step.actor], player(draw.after, draw.step.actor).faction);
}
const previous = player(f.game, f.richese).noField!.tokens.find(t => t.value === 0)!;
assert.equal(player(f.firstShipment.after, f.richese).spice, player(f.firstShipment.before, f.richese).spice - 1);
assert.equal(player(f.firstShipment.after, f.richese).reserves, player(f.firstShipment.before, f.richese).reserves);
assert.equal(player(f.game, f.richese).noField!.lastShipped, previous.id);
const request = pairedSkillsRicheseCunningRequest(f);
reject(f.game, f.richese, { ...request, noField: previous.id });
reject(f.game, f.richese, { ...request, revealedToken: previous.id });
reject(f.game, f.richese, { ...request, revealedToken: request.noField });
reject(f.game, f.richese, { ...request, smuggler: true });
reject(f.game, f.richese, { ...request, amount: 8 });
holdPairedSkillsCard(f.game, f.observer, card => card.effect === 'karama', f.staging);
const declared = applyAction(f.game, f.richese, request);
assert.equal(declared.response?.kind, 'richeseNoField');
assert.equal(declared.pendingShipment!.cost, 1);
assert.equal(declared.pendingShipment!.amount, 1);
assert.equal(declared.pendingShipment!.noFieldSkillProof, undefined, 'Original signed pair, not an invented ordinary skill proof');
assert.equal(declared.pendingShipment!.smugglerCompanion, undefined);
assert.deepEqual(player(declared, f.richese).forces, player(f.game, f.richese).forces);
assert.equal(player(declared, f.richese).spice, player(f.game, f.richese).spice);
const changed = pairedSkillsSnapshot(declared);
changed.pendingShipment!.cost = 0;
const responder = changed.players.find(p => !changed.response!.passed.includes(p.id))!.id;
reject(changed, responder, { type: 'passResponse' });
const shipped = allowPairedChoamSkills(pairedSkillsSnapshot(declared));
assertPair(f, shipped, 5);
reject(shipped, f.richese, request);
const continued = allowPairedChoamSkills(shipped);
assert.equal(player(continued, f.richese).spice, player(shipped, f.richese).spice, 'Settled response cannot repeat or refund the invoice');
assert.equal(player(continued, f.guild).spice, player(shipped, f.guild).spice);
});
void test('paired Richese immediate group is reserve-capped; voluntary reveal cannot invent concealed counters and next native shipment respects original marker history', () => {
const f = createPairedChoamNexusSkillsFixture({ advanced: false, reserveCap: 2 });
const shipped = allowPairedChoamSkills(applyAction(f.game, f.richese, pairedSkillsRicheseCunningRequest(f)));
assertPair(f, shipped, 2);
const revealed = revealPairedSkillsRichese(shipped, f.richese);
assert.equal(player(revealed, f.richese).noField!.deployed, null);
assert.equal(player(revealed, f.richese).forces[f.location], 2);
assert.equal(player(revealed, f.richese).reserves, 0);
const previous = player(revealed, f.richese).noField!.lastShipped!;
let next = advancePairedChoamSkills(revealed, g => g.turn === 3 && g.phase === 1 && pairedSkillsClean(g));
// Preserve already played discards; order only unused native land cards.
const land = next.spiceDeck.filter(c => 'territory' in c).slice(0, 2);
assert.equal(land.length, 2);
for (const [at, card] of land.entries()) next.spiceDeck.splice(at, 0, next.spiceDeck.splice(next.spiceDeck.indexOf(card), 1)[0]);
next = advancePairedChoamSkills(next, g => g.turn === 3 && g.phase === 5 && g.active === f.richese && pairedSkillsClean(g));
reject(next, f.richese, { type: 'ship', noField: previous, event: player(next, f.richese).noFieldEvent, territory: 'polar_sink', sector: 0 });
const zero = player(next, f.richese).noField!.tokens.find(t => t.value === 0)!;
const accepted = allowPairedChoamSkills(applyAction(next, f.richese, { type: 'ship', noField: zero.id, event: player(next, f.richese).noFieldEvent, territory: 'polar_sink', sector: 0 }));
assert.equal(player(accepted, f.richese).noField!.lastShipped, zero.id);
assert.equal(player(accepted, f.richese).noField!.deployed!.tokenId, zero.id);
assert.equal(player(accepted, f.richese).spice, player(next, f.richese).spice - 2);
assert.equal(player(accepted, f.richese).forces['polar_sink:0'], 18);
custody(accepted);
});
void test('paired skills Cunning stopped by physical Karama spends only Nexus; ordinary shipment remains a separate single payment', () => {
const f = createPairedChoamNexusSkillsFixture();
const karama = holdPairedSkillsCard(f.game, f.guild, c => c.kind === 'special' && c.effect === 'karama', f.staging);
const before = pairedSkillsSnapshot(f.game), request = pairedSkillsRicheseCunningRequest(f);
const declared = applyAction(f.game, f.richese, request);
const stopped = allowPairedChoamSkills(applyAction(declared, f.guild, { type: 'card', mode: 'cancel', card: karama.id }));
assert.equal(stopped.nexusRicheseCunningLast!.stage, 'stopped');
assert.equal(stopped.nexusCards!.cards!.hands[f.richese], null);
for (const p of before.players) assert.equal(player(stopped, p.id).spice, p.spice);
assert.deepEqual(player(stopped, f.richese).forces, player(before, f.richese).forces);
assert.equal(player(stopped, f.richese).reserves, player(before, f.richese).reserves);
assert.deepEqual(player(stopped, f.richese).noField, player(before, f.richese).noField);
assert.equal(player(stopped, f.richese).shipped, false);
assert.equal(stopped.discard.filter(c => c.id === karama.id).length, 1);
reject(stopped, f.richese, request);
const ordinary = allowPairedChoamSkills(applyAction(stopped, f.richese, { type: 'ship', amount: 1, territory: 'habbanya_ridge_sietch', sector: Number(f.location.split(':')[1]) }));
assert.equal(player(ordinary, f.richese).spice, player(stopped, f.richese).spice - 1);
assert.equal(player(ordinary, f.guild).spice, player(stopped, f.guild).spice + 1);
assert.equal(player(ordinary, f.richese).forces[f.location], 1);
assert.deepEqual(ordinary.leaderSkills!.assignments, before.leaderSkills!.assignments);
custody(ordinary);
});
void test('genuine unpaid Richese pair preserves both original markers, physical Nexus, reserves and skill assignment', () => {
const f = createPairedChoamNexusSkillsFixture();
const amount = player(f.game, f.richese).spice;
assert.ok(amount > 0);
const unpaid = applyAction(f.game, f.richese, { type: 'bribe', target: f.guild, amount });
assert.equal(player(unpaid, f.richese).spice, 0);
assert.equal(player(unpaid, f.guild).bribes, player(f.game, f.guild).bribes + amount, 'Wallet is depleted by a genuine native bribe, never assigned');
const before = pairedSkillsSnapshot(unpaid);
reject(unpaid, f.richese, pairedSkillsRicheseCunningRequest(f, unpaid));
assert.equal(unpaid.nexusCards!.cards!.hands[f.richese], 'richese');
assert.deepEqual(player(unpaid, f.richese).noField, player(before, f.richese).noField);
custody(unpaid);
});
for (const advanced of [false, true]) for (const trained of [false, true])
void test(`paired ${advanced ? 'Advanced' : 'Basic'} voluntary reveal → ordinary ${trained ? 'skilled' : 'normal'} Suk rescue; native CHOAM support income stays separate from Collection`, () => {
const f = createPairedChoamNexusSkillsFixture({ advanced });
let game = allowPairedChoamSkills(applyAction(f.game, f.richese, pairedSkillsRicheseCunningRequest(f)));
game = revealPairedSkillsRichese(game, f.richese);
assert.equal(player(game, f.richese).noField!.deployed, null);
assert.equal(player(game, f.richese).forces[f.location], 8);
assert.equal(player(game, f.richese).reserves, 12);
placePairedSkillsForce(game, f.guild, f.location, 3, f.staging);
placePairedSkillsForce(game, f.choam, `arrakeen:${territory('arrakeen').sectors[0]}`, 1, f.staging);
game = openPairedSkillsBattle(game, f.richese, f.guild, { band: trained ? 'skilled' : 'normal' });
const plans = pairedSkillsBattlePlans(game, f.richese, f.guild, 2, trained);
assert.equal(plans[0].action.leader === f.trainers.richese, trained);
for (const next of plans) game = allowPairedChoamSkills(stepPairedChoamSkills(game, next));
assert.ok(game.battle?.revealed);
const revealed = pairedSkillsSnapshot(game), quote = quotePairedSkillsBattle(game);
assert.equal(quote.result, 'normal');
assert.equal(quote.winner, f.richese);
assert.equal(quote.sukGraduate!.mode, trained ? 'skilled' : 'normal');
assert.equal(viewGame(game, f.richese).battle!.ownForces!.normal, 8, 'Both marker groups now have ordinary native physical force identity');
const payment = quote.payments.find(p => p.player === f.richese);
assert.equal(payment?.ownPayment ?? 0, advanced ? 2 : 0);
assert.equal(payment?.bankSupport ?? 0, 0);
if (advanced) {
assert.equal(quote.choamIncome!.owner, f.choam);
assert.ok(quote.choamIncome!.amount > 0);
} else assert.equal(quote.choamIncome, null);
game = finishPairedSkillsBattle(game);
assert.equal(game.lastBattleContext!.winner, f.richese);
assert.equal(game.lastBattleContext!.result, 'normal');
assert.equal(player(game, f.richese).forces[f.location], trained ? 7 : 6);
assert.equal(player(game, f.richese).reserves, 13, 'Skilled Suk keeps one rescued casualty on its original sector and returns the other; normal Suk returns one');
assert.equal(player(game, f.richese).tanks, trained ? 0 : 1);
assert.equal(player(game, f.guild).tanks, 3, 'Declining a traitor call still loses all opposing physical forces in an ordinary battle');
game = advancePairedChoamSkillsToPhase(game, 7);
const collection = quoteSpiceCollection({ ...game, players: game.players.map(p => ({ ...p, spice: 0 })) }).receipts;
assert.equal(collection.find(r => r.player === f.choam)!.strongholds, advanced ? 2 : 0, 'Native Arrakeen Collection is a separate later credit, not battle support income');
for (const p of revealed.players) {
const support = quote.payments.find(r => r.player === p.id)?.ownPayment ?? 0;
const income = quote.choamIncome?.owner === p.id ? quote.choamIncome.amount : 0;
const city = collection.find(r => r.player === p.id);
assert.equal(player(game, p.id).spice, p.spice - support + income + (city?.strongholds ?? 0) + (city?.collected ?? 0));
}
custody(game);
});
void test('CHOAM own Cunning converts one original Special into printed Kulon fuel once; ordinary Planetologist route consumes physical forces, not another card', () => {
const f = createPairedChoamNexusSkillsFixture({ advanced: false });
let game = advancePairedChoamSkills(f.movementStart, g => g.phase === 5 && g.active === f.choam && pairedSkillsClean(g));
const assignment = game.leaderSkills!.assignments.find(a => a.owner === f.choam)!;
assert.equal(assignment.skill, 'planetologist');
const fuel = holdPairedSkillsCard(game, f.choam, isPlanetologistBattleSpecialCard, f.staging);
holdPairedSkillsCard(game, f.guild, card => card.effect === 'karama', f.staging);
const route = stagePairedSkillsPlanetologistRoute(game, f.choam, f.staging);
const request = pairedSkillsChoamCunningRequest(game, f.choam, fuel.id), before = pairedSkillsSnapshot(game);
reject(before, f.choam, route.action);
reject(before, f.choam, { ...request, nexus: 'stale-original-offer' });
game = applyAction(game, f.choam, request);
assert.equal(game.response?.kind, 'choamWorthless');
assert.equal(game.nexusCards!.cards!.hands[f.choam], null);
assert.deepEqual(player(game, f.choam).hand.find(c => c.id === fuel.id), fuel, 'Original response retains printed fuel custody until resolution');
game = allowPairedChoamSkills(game);
assert.equal(game.nexusChoamLast!.stage, 'complete');
assert.equal(game.discard.filter(c => c.id === fuel.id).length, 1);
assert.equal(player(game, f.choam).hand.some(c => c.id === fuel.id), false);
assert.equal(game.choamMovement!.bonus, 1);
assert.deepEqual(game.leaderSkills!.assignments, before.leaderSkills!.assignments);
reject(game, f.choam, request);
reject(game, f.choam, { ...route.action, noField: 'not-an-ordinary-route' });
const moved = allowPairedChoamSkills(applyAction(game, f.choam, route.action));
assert.equal(player(moved, f.choam).forces[route.from] ?? 0, 0);
assert.equal(player(moved, f.choam).forces[route.to], 1);
assert.equal(player(moved, f.choam).moved, 1);
assert.equal(moved.discard.filter(c => c.id === fuel.id).length, 1);
for (const p of before.players) assert.equal(player(moved, p.id).spice, p.spice);
custody(moved);
});
void test('canceling actual CHOAM Cunning consumes Karama and Nexus, retains the original skill-eligible Special and gives no Kulon route', () => {
const f = createPairedChoamNexusSkillsFixture({ advanced: false });
let game = advancePairedChoamSkills(f.movementStart, g => g.phase === 5 && g.active === f.choam && pairedSkillsClean(g));
const fuel = holdPairedSkillsCard(game, f.choam, isPlanetologistBattleSpecialCard, f.staging);
const karama = holdPairedSkillsCard(game, f.guild, c => c.kind === 'special' && c.effect === 'karama' && c.id !== fuel.id, f.staging);
const route = stagePairedSkillsPlanetologistRoute(game, f.choam, f.staging), before = pairedSkillsSnapshot(game);
const request = pairedSkillsChoamCunningRequest(game, f.choam, fuel.id);
game = applyAction(game, f.choam, request);
game = allowPairedChoamSkills(applyAction(game, f.guild, { type: 'card', mode: 'cancel', card: karama.id }));
assert.equal(game.nexusChoamLast!.stage, 'canceled');
assert.equal(game.nexusCards!.cards!.hands[f.choam], null);
assert.deepEqual(player(game, f.choam).hand.find(c => c.id === fuel.id), fuel);
assert.equal(game.discard.some(c => c.id === fuel.id), false);
assert.equal(game.discard.filter(c => c.id === karama.id).length, 1);
assert.equal(game.choamMovement?.bonus ?? 0, 0);
assert.deepEqual(game.leaderSkills!.assignments, before.leaderSkills!.assignments);
reject(game, f.choam, route.action);
reject(game, f.choam, request);
for (const p of before.players) assert.equal(player(game, p.id).spice, p.spice);
custody(game);
});
void test('original authenticated setup preserves already-dealt native hands, saved offers and IDs through genuine native continuation', () => {
const lobby = createGame('AUTHPAIREDE2SKILLS', newPlayer('auth-r', 'R', 'richese'), true, ['choam']);
joinGame(lobby, newPlayer('auth-c', 'C', 'choam'));
joinGame(lobby, newPlayer('auth-g', 'G', 'guild'));
joinGame(lobby, newPlayer('auth-a', 'A', 'atreides'));
const setup = initializePairedChoamNexusSkills({ initial: lobby }), saved = pairedSkillsSnapshot(setup);
const f = createPairedChoamNexusSkillsFixture({ initial: setup });
assert.deepEqual(setup, saved);
assert.deepEqual(f.initial, saved, 'Actual initialized offers and first deal are not recreated');
for (const p of saved.players) assert.deepEqual(player(f.afterSetup, p.id).hand, p.hand);
assert.deepEqual([f.richese, f.choam, f.guild, f.observer], ['auth-r', 'auth-c', 'auth-g', 'auth-a']);
const richeseOffer = setup.leaderSkills!.offers[f.richese];
const absent = setup.leaderSkills!.deck.find(skill => !richeseOffer.cards.includes(skill))!;
assert.throws(() => createPairedChoamNexusSkillsFixture({ initial: setup, richeseSkill: absent }));
assert.deepEqual(setup, saved, 'An unavailable requested skill cannot replace saved offers');
for (const difficulty of DIFFICULTIES) {
const view = viewGame(f.game, f.richese);
view.players.find(p => p.id === f.richese)!.bot = difficulty;
const action = botActions(view)[0];
assert.ok(action?.type === 'ship' && action.noField && action.revealedToken, difficulty);
const shipped = allowPairedChoamSkills(applyAction(f.game, f.richese, action));
assert.equal(shipped.nexusRicheseCunningLast!.stage, 'shipped', difficulty);
assert.ok(typeof action.territory === 'string');
const cost = reserveShipmentCost({ faction: 'richese', halfRate: false }, territory(action.territory).type, 1);
assert.equal(player(shipped, f.richese).spice, player(f.game, f.richese).spice - cost);
assert.equal(shipped.nexusCards!.cards!.hands[f.richese], null);
custody(shipped);
}
});
void test('both original E2 natives actually deal and assign all14 custody under Basic/Advanced two-to-six-seat native starts', () => {
const additional = ['guild', 'atreides', 'emperor', 'harkonnen'] as const;
for (const advanced of [false, true]) for (const seats of [2, 3, 4, 5, 6]) {
const setup = initializePairedChoamNexusSkills({ advanced, factions: ['richese', 'choam', ...additional.slice(0, seats - 2)] });
const starts = pairedSkillsSnapshot(setup);
const game = completePairedChoamSkillsSetup(setup);
assert.equal(game.leaderSkills!.assignments.length, seats);
for (const p of starts.players) {
assert.deepEqual(player(game, p.id).hand, p.hand);
}
assert.equal(player(game, 'richese').noField!.deployed, null);
assert.equal(game.nexusCards!.cards!.deck.length, 12, 'Original Nexus cards are still physically undealt before the first real worm');
custody(game);
const firstStorm = advancePairedChoamSkillsToPhase(game, 1);
assert.equal(firstStorm.turn, 1);
assert.equal(firstStorm.phase, 1);
custody(firstStorm);
}
});
@@ -0,0 +1,313 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { territory } from '../game/board';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { validateLeaderSkills } from '../game/leader-skills';
import { nexusFaceDancersAction } from '../game/nexus-tleilaxu-options';
import {
advancePairedIxNexusSkills, beginPairedIxNexusSkillsCunning, choosePairedIxNexusSkillsSecondBattle,
createPairedIxNexusSkillsFixture, finishPairedIxNexusSkillsBattle, finishPairedIxNexusSkillsTurn,
pairedIxNexusSkillsPlayer as player, preparePairedIxNexusSkillsBattle, preparePairedIxNexusSkillsExpiryBattle,
quotePairedIxNexusSkillsBattle, revealPairedIxNexusSkillsBattle, stepPairedIxNexusSkills,
type PairedIxNexusSkillsFixture, type PairedIxNexusSkillsStep,
} from './fixture-paired-ix-nexus-skills';
function reject(game: Game, actor: string, action: Action): void {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before, 'Rejected consumer actions cannot consume physical cards, forces, spice or receipts');
}
function treachery(game: Game): string[] {
return [...game.deck, ...game.discard, ...(game.ixSetupCards ?? []), ...(game.ixAuction?.cards ?? []),
...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? []),
...game.players.flatMap(p => p.hand)].map(c => c.id).sort();
}
function custody(game: Game, original: Game): void {
assert.equal(treachery(game).length, 47);
assert.deepEqual(treachery(game), treachery(original));
assert.equal(new Set(treachery(game)).size, 47);
validateLeaderSkills(game.leaderSkills!, game.players);
const skills = [...game.leaderSkills!.deck, ...Object.values(game.leaderSkills!.offers).flatMap(o => o.cards),
...game.leaderSkills!.assignments.map(a => a.skill)].sort();
assert.deepEqual(skills, LEADER_SKILL_CARDS.map(c => c.id).sort());
const nexus = game.nexusCards!.cards!;
const faces = [...nexus.deck, ...nexus.discard, ...Object.values(nexus.hands).filter((c): c is NonNullable<typeof c> => c !== null)];
assert.equal(faces.length, 12); assert.equal(new Set(faces).size, 12);
const stock = (g: Game) => [...(g.traitorReserve ?? []), ...g.players.flatMap(p => [...p.traitors, ...(p.faceDancers ?? []).map(c => c.leader)])].sort();
assert.deepEqual(stock(game), stock(original)); assert.equal(new Set(stock(game)).size, stock(game).length);
for (const p of game.players) {
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum, n) => sum + n, 0), 20, p.faction);
if (p.elites) {
const old = player(original, p.id).elites!;
assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0),
old.reserves + old.tanks + Object.values(old.forces).reduce((sum, n) => sum + n, 0));
for (const [key, n] of Object.entries(p.elites.forces)) assert.ok(n <= (p.forces[key] ?? 0));
}
}
}
function city(game: Game, actor: string): number {
return game.phase === 7 && !game.phaseOpening ? quoteSpiceCollection(game).receipts
.filter(r => r.player === actor).reduce((sum, r) => sum + r.collected + r.strongholds, 0) : 0;
}
function money(revealed: Game, settled: Game): void {
const quote = quotePairedIxNexusSkillsBattle(revealed);
for (const p of revealed.players) {
const paid = quote.payments.find(r => r.player === p.id)?.ownPayment ?? 0;
const bounty = quote.bounty?.player === p.id ? quote.bounty.amount : 0;
assert.equal(player(settled, p.id).spice, p.spice - paid + bounty + city(settled, p.id), p.faction);
}
assert.deepEqual(quote.strongholdIncome, [], 'Printed city collection is separate; this profile has no Stronghold Cards');
}
function chooseCyborgLosses(state: Game, f: PairedIxNexusSkillsFixture): Game {
const losses = finishPairedIxNexusSkillsBattle(state, 'losses');
assert.equal(losses.decision?.kind, 'battleLosses');
if (losses.decision?.kind !== 'battleLosses') throw Error('Actual typed casualty choice required');
const choice = losses.decision.options.findIndex(o => o.normal === 0 && o.elite === 3); assert.ok(choice >= 0);
assert.equal(player(losses, f.ixians).tanks, player(state, f.ixians).tanks, 'Typed commitment precedes physical Suk settlement');
return stepPairedIxNexusSkills(losses, { actor: f.ixians, action: { type: 'decision', choice } });
}
function dance(f: PairedIxNexusSkillsFixture, pending: Game): Game {
assert.equal(pending.decision?.kind, 'faceDance');
const count = player(pending, f.ixians).forces[f.location]; assert.ok(count >= 2);
return stepPairedIxNexusSkills(pending, { actor: f.tleilaxu, action: { type: 'decision', reveal: true,
sources: { [f.replacementSource]: 2, reserves: count - 2 }, sector: territory(f.kind).sectors[0] } });
}
for (const advanced of [false, true]) for (const band of ['normal', 'skilled'] as const) {
void test(`original paired E1 ${advanced ? 'Advanced' : 'Basic'} ${band} Warmaster: full unsupported Suboids, real bonus and typed losses`, () => {
const f = createPairedIxNexusSkillsFixture({ advanced, band, skill: 'warmaster', dial: 3, support: 0 });
assert.equal(f.beforeFirstMentat.turn, 1); assert.equal(f.afterFirstMentat.turn, 2);
assert.equal(f.beforeAlliance.nexus, true);
assert.equal(player(f.afterNexusDraw, f.partners[0]).ally, f.partners[1]);
assert.equal(player(f.afterNexusDraw, f.ixians).ally, null);
assert.equal(player(f.afterNexusDraw, f.tleilaxu).ally, null);
assert.equal(f.afterNexusDraw.nexusCards!.cards!.hands[f.ixians], 'ixians');
assert.equal(f.afterNexusDraw.nexusCards!.cards!.hands[f.tleilaxu], 'tleilaxu');
custody(f.afterNexusDraw, f.afterSetup);
const ready = preparePairedIxNexusSkillsBattle(f), offer = viewGame(ready, f.ixians).nexusSuboids!.offer!;
reject(ready, f.tleilaxu, { type: 'nexusSuboids', event: offer.event });
const active = beginPairedIxNexusSkillsCunning(f, ready);
assert.deepEqual(player(active, f.ixians).forces, player(ready, f.ixians).forces);
assert.deepEqual(player(active, f.ixians).elites, player(ready, f.ixians).elites);
assert.equal(player(active, f.ixians).spice, player(ready, f.ixians).spice);
assert.equal(active.nexusCards!.cards!.hands[f.ixians], null);
reject(active, f.ixians, { type: 'nexusSuboids', event: offer.event });
const revealed = revealPairedIxNexusSkillsBattle(f, { state: active });
const quote = quotePairedIxNexusSkillsBattle(revealed);
assert.equal(quote.result, 'normal'); assert.equal(quote.winner, f.ixians);
assert.equal(quote.casualties!.forces.normalFixedHalf, false);
assert.equal(quote.casualties!.forces.normalFreeSupport, true);
const side = revealed.battle!.attacker === f.ixians ? 'attacker' : 'defender';
assert.deepEqual(quote.leaderSkillBonuses[side].applied, [{ skill: 'warmaster', amount: band === 'skilled' ? 3 : 1, mode: band }]);
assert.equal(quote.leaderStrengths[side], player(revealed, f.ixians).leaders.find(l => l.id === f.planActions[0].action.leader)!.strength);
assert.ok(quote.casualties!.options.some(o => o.normal === 3 && o.elite === 0));
if (advanced) {
const ordinary = quotePairedIxNexusSkillsBattle(revealPairedIxNexusSkillsBattle(f));
assert.ok(!ordinary.casualties!.options.some(o => o.normal === 3 && o.elite === 0));
assert.equal(quote.payments.find(r => r.player === f.ixians)!.ownPayment, 0);
assert.equal(quote.payments.find(r => r.player === f.ixians)!.bankSupport, 0);
} else assert.deepEqual(quote.payments, []);
const losses = finishPairedIxNexusSkillsBattle(revealed, 'losses');
if (losses.decision?.kind !== 'battleLosses') throw Error('Missing actual typed winner casualties');
const choice = losses.decision.options.findIndex(o => o.normal === 3 && o.elite === 0); assert.ok(choice >= 0);
const settled = finishPairedIxNexusSkillsBattle(stepPairedIxNexusSkills(losses, { actor: f.ixians, action: { type: 'decision', choice } }));
assert.equal(player(settled, f.ixians).forces[f.location], 3);
assert.equal(player(settled, f.ixians).elites!.forces[f.location], 3);
assert.equal(player(settled, f.ixians).tanks, player(revealed, f.ixians).tanks + 3);
assert.equal(player(settled, f.ixians).elites!.tanks, player(revealed, f.ixians).elites!.tanks);
assert.equal(player(settled, f.opponent).tanks, player(revealed, f.opponent).tanks + 6);
money(revealed, settled); custody(settled, f.afterSetup);
});
void test(`original paired E1 ${advanced ? 'Advanced' : 'Basic'} ${band} Suk: physical rescue precedes equal Suboid substitution, paid once`, () => {
const f = createPairedIxNexusSkillsFixture({ advanced, band, skill: 'suk-graduate' });
const revealed = revealPairedIxNexusSkillsBattle(f, { cunning: true }), quote = quotePairedIxNexusSkillsBattle(revealed);
assert.equal(quote.winner, f.ixians); assert.equal(quote.sukGraduate!.mode, band);
if (advanced) {
const payment = quote.payments.find(r => r.player === f.ixians)!;
assert.equal(payment.ownPayment, 1); assert.equal(payment.bankSupport, 0);
} else assert.deepEqual(quote.payments, []);
const original = player(revealed, f.ixians);
let selected = chooseCyborgLosses(revealed, f);
let event = '', choice = 0;
if (band === 'skilled') {
const rescue = finishPairedIxNexusSkillsBattle(selected, 'rescue');
if (rescue.decision?.kind !== 'sukRescue') throw Error('Missing original skilled Suk rescue');
assert.equal(player(rescue, f.ixians).tanks, original.tanks, 'No physical losses occur before the skilled rescue choice');
choice = rescue.decision.options.findIndex(o => o.normal === 0 && o.elite === 1 && o.kept?.kind === 'elite' && o.kept.key === f.location);
assert.ok(choice >= 0); event = rescue.decision.event;
selected = stepPairedIxNexusSkills(rescue, { actor: f.ixians, action: { type: 'decision', event, choice } });
} else {
assert.equal(selected.lastBattleContext!.sukRescue!.completed, true, 'The sole normal elite rescue is applied automatically');
}
const pending = finishPairedIxNexusSkillsBattle(selected, 'substitution');
assert.equal(pending.decision?.kind, 'ixSubstitution');
const p = player(pending, f.ixians);
assert.equal(p.tanks, original.tanks + 2); assert.equal(p.elites!.tanks, original.elites!.tanks + 2);
assert.equal(p.battleLosses, original.battleLosses + 2);
assert.equal(p.reserves, original.reserves + (band === 'normal' ? 1 : 0));
assert.equal(p.elites!.reserves, original.elites!.reserves + (band === 'normal' ? 1 : 0));
assert.equal(p.forces[f.location], band === 'normal' ? 3 : 4);
assert.equal(p.elites!.forces[f.location] ?? 0, band === 'normal' ? 0 : 1);
assert.equal(p.spice, original.spice - (advanced ? 1 : 0));
reject(pending, f.ixians, { type: 'decision', event, choice });
reject(pending, f.ixians, { type: 'decision', sources: { [f.location]: 2 }, recover: { [f.location]: 3 } });
const exchanged = stepPairedIxNexusSkills(pending, { actor: f.ixians, action: { type: 'decision', sources: { [f.location]: 2 }, recover: { [f.location]: 2 } } });
const settled = finishPairedIxNexusSkillsBattle(exchanged), after = player(settled, f.ixians);
assert.equal(after.forces[f.location], p.forces[f.location]);
assert.equal(after.elites!.forces[f.location], band === 'normal' ? 2 : 3);
assert.equal(after.tanks, p.tanks, 'Equal physical exchange does not count the original Cyborg loss twice');
assert.equal(after.elites!.tanks, original.elites!.tanks);
assert.equal(after.battleLosses, p.battleLosses);
assert.equal(after.reserves, p.reserves); assert.equal(after.elites!.reserves, p.elites!.reserves);
assert.equal(settled.lastBattleContext!.sukRescue!.completed, true);
assert.equal(settled.lastBattleContext!.ixSubstitution!.completed, true);
money(revealed, settled); custody(settled, f.afterSetup);
});
}
void test('own sealed plan blocks Cunning; active Suboids persist across real battles and expire at the actual next turn', () => {
const f = createPairedIxNexusSkillsFixture({ skill: 'warmaster', dial: 3, support: 0, secondBattle: true });
const prepared = preparePairedIxNexusSkillsBattle(f), offer = viewGame(prepared, f.ixians).nexusSuboids!.offer!;
const sealed = stepPairedIxNexusSkills(prepared, f.planActions[0]);
reject(sealed, f.ixians, { type: 'nexusSuboids', event: offer.event });
const settled = finishPairedIxNexusSkillsBattle(revealPairedIxNexusSkillsBattle(f, { cunning: true }));
const next = choosePairedIxNexusSkillsSecondBattle(f, settled);
assert.equal(viewGame(next, f.ixians).nexusSuboids!.active, true);
const unused = (actor: string) => player(next, actor).leaders.find(l => !l.dead && !l.usedAt &&
!next.leaderSkills!.assignments.some(assignment => assignment.leader === l.id))!;
const plans: PairedIxNexusSkillsStep[] = [
{ actor: f.ixians, action: { type: 'battlePlan', leader: unused(f.ixians).id, dial: 3, support: 0, weapon: null, defense: null } },
{ actor: f.opponent, action: { type: 'battlePlan', leader: unused(f.opponent).id, dial: 0, support: 0, weapon: null, defense: null } },
];
const second = revealPairedIxNexusSkillsBattle(f, { state: next, plans });
assert.equal(quotePairedIxNexusSkillsBattle(second).casualties!.forces.normalFreeSupport, true);
const secondSettled = finishPairedIxNexusSkillsBattle(second), turn = finishPairedIxNexusSkillsTurn(secondSettled);
assert.equal(viewGame(turn, f.ixians).nexusSuboids!.active, false);
const expired = preparePairedIxNexusSkillsExpiryBattle(f, turn);
const leader = player(expired, f.ixians).leaders.find(l => !l.dead &&
!expired.leaderSkills!.assignments.some(assignment => assignment.leader === l.id))!;
reject(expired, f.ixians, { type: 'battlePlan', leader: leader.id, dial: 3, support: 0, weapon: null, defense: null });
const legal = stepPairedIxNexusSkills(expired, { actor: f.ixians, action: { type: 'battlePlan', leader: leader.id, dial: 1.5, support: 0, weapon: null, defense: null } });
assert.equal(legal.battle!.plans[f.ixians].dial, 1.5); custody(legal, f.afterSetup);
});
for (const advanced of [false, true]) void test(`original paired ${advanced ? 'Advanced' : 'Basic'} Face Dance: Suk/card cleanup/rewards finish first; partial Cunning draws before retirement`, () => {
const f = createPairedIxNexusSkillsFixture({ advanced, program: 'faceDance', skill: 'suk-graduate' });
const revealed = revealPairedIxNexusSkillsBattle(f, { cunning: true }), quote = quotePairedIxNexusSkillsBattle(revealed);
const enemy = player(revealed, f.opponent).leaders.find(l => l.id === f.planActions[1].action.leader)!;
assert.deepEqual(quote.bounty, { player: f.ixians, amount: enemy.strength });
const cards = finishPairedIxNexusSkillsBattle(revealed, 'cards');
assert.equal(cards.decision?.kind, 'battleCards');
assert.equal(cards.lastBattleContext!.sukRescue!.completed, true);
const defense = f.planActions[0].action.defense as string;
const cleaned = stepPairedIxNexusSkills(cards, { actor: f.ixians, action: { type: 'decision', discard: [defense] } });
const pending = finishPairedIxNexusSkillsBattle(cleaned, 'faceDance');
assert.ok(pending.discard.some(c => c.id === defense));
assert.equal(player(pending, f.opponent).leaders.find(l => l.id === enemy.id)!.dead, true);
money(revealed, pending);
const winner = structuredClone(player(pending, f.ixians));
const oldStock = structuredClone(player(pending, f.tleilaxu).faceDancers!);
assert.ok(oldStock.some(c => c.leader === f.trainer && !c.revealed));
reject(pending, f.tleilaxu, { type: 'decision', reveal: true, sources: { reserves: winner.forces[f.location] + 1 }, sector: territory(f.kind).sectors[0] });
const after = dance(f, pending), returned = player(after, f.ixians), replacement = player(after, f.tleilaxu);
assert.equal(returned.forces[f.location] ?? 0, 0); assert.equal(returned.elites!.forces[f.location] ?? 0, 0);
assert.equal(returned.reserves, winner.reserves + winner.forces[f.location]);
assert.equal(returned.elites!.reserves, winner.elites!.reserves + (winner.elites!.forces[f.location] ?? 0));
assert.equal(returned.tanks, winner.tanks); assert.equal(returned.battleLosses, winner.battleLosses);
assert.equal(returned.elites!.tanks, winner.elites!.tanks);
assert.equal(returned.leaders.find(l => l.id === f.trainer)!.deaths, winner.leaders.find(l => l.id === f.trainer)!.deaths + 1);
assert.equal(returned.leaders.find(l => l.id === f.trainer)!.dead, true);
assert.equal(after.leaderSkills!.assignments.some(a => a.leader === f.trainer), false);
assert.equal(after.leaderSkills!.deck.filter(s => s === f.skill).length, 1);
assert.deepEqual(returned.hand, winner.hand);
for (const p of pending.players) assert.equal(player(after, p.id).spice, p.spice + city(after, p.id), 'Face Dance grants no extra bounty/support charge');
assert.equal(replacement.forces[f.location], winner.forces[f.location]);
assert.equal(replacement.forces[f.replacementSource], 1);
assert.equal(replacement.reserves, player(pending, f.tleilaxu).reserves - winner.forces[f.location] + 2);
const revealedStock = replacement.faceDancers!.filter(c => c.revealed); assert.equal(revealedStock.length, 1);
const untouched = replacement.faceDancers!.filter(c => !c.revealed);
assert.deepEqual(untouched, oldStock.filter(c => c.leader !== f.trainer));
const view = viewGame(after, f.tleilaxu), offer = view.nexusTleilaxu!.cunning!;
const action = nexusFaceDancersAction(view, offer.event); assert.ok(action);
const reserve = [...after.traitorReserve!], allowance = replacement.faceDancerReplacedTurn;
reject(after, f.tleilaxu, { ...action, leaders: [f.trainer] });
const replaced = stepPairedIxNexusSkills(after, { actor: f.tleilaxu, action });
assert.deepEqual(player(replaced, f.tleilaxu).faceDancers!.filter(c => untouched.some(old => old.leader === c.leader)), untouched);
assert.deepEqual(player(replaced, f.tleilaxu).faceDancers!.filter(c => !untouched.some(old => old.leader === c.leader)), [{ leader: reserve[0], revealed: false }]);
assert.notEqual(reserve[0], f.trainer);
assert.deepEqual([...replaced.traitorReserve!].sort(), [...reserve.slice(1), f.trainer].sort());
assert.equal(player(replaced, f.tleilaxu).faceDancerReplacedTurn, allowance);
assert.equal(replaced.nexusCards!.cards!.hands[f.tleilaxu], null);
reject(replaced, f.tleilaxu, action); custody(replaced, f.afterSetup);
const mentat = advancePairedIxNexusSkills(replaced, g => g.phase === 8 && !g.phaseOpening && !g.response && !g.decision);
const ordinary = stepPairedIxNexusSkills(mentat, { actor: f.tleilaxu, action: { type: 'replaceFaceDancer', leader: untouched[0].leader } });
const done = advancePairedIxNexusSkills(ordinary, g => !g.response && !g.decision);
assert.equal(player(done, f.tleilaxu).faceDancerReplacedTurn, done.turn);
reject(done, f.tleilaxu, { type: 'replaceFaceDancer', leader: player(done, f.tleilaxu).faceDancers![0].leader });
const turn = finishPairedIxNexusSkillsTurn(done);
assert.equal(turn.leaderSkills!.deck.filter(s => s === f.skill).length, 1); custody(turn, f.afterSetup);
if (advanced) {
const revival = advancePairedIxNexusSkills(turn, g => g.phase === 4 && !g.phaseOpening && !g.response && !g.decision);
reject(revival, f.tleilaxu, { type: 'reviveForeignGhola', leader: enemy.id });
assert.equal(player(revival, f.opponent).leaders.find(l => l.id === enemy.id)!.gholaBy, undefined,
'An actual dead foreign disc does not bypass the native five-living-leader ghola limit');
}
});
void test('normal-band Face Dance selects a genuine matching untrained disc and cannot return its living trainer skill', () => {
const f = createPairedIxNexusSkillsFixture({ program: 'faceDance', band: 'normal', skill: 'warmaster' });
const selected = f.planActions[0].action.leader as string;
assert.notEqual(selected, f.trainer);
const original = structuredClone(player(f.afterSetup, f.tleilaxu).faceDancers!);
assert.ok(original.some(c => c.leader === selected && !c.revealed));
const revealed = revealPairedIxNexusSkillsBattle(f, { cunning: true });
const quote = quotePairedIxNexusSkillsBattle(revealed);
const side = revealed.battle!.attacker === f.ixians ? 'attacker' : 'defender';
assert.deepEqual(quote.leaderSkillBonuses[side].applied, [{ skill: 'warmaster', amount: 1, mode: 'normal' }]);
const pending = finishPairedIxNexusSkillsBattle(revealed, 'faceDance');
assert.deepEqual(player(pending, f.tleilaxu).faceDancers, original);
const after = dance(f, pending);
assert.equal(player(after, f.ixians).leaders.find(l => l.id === selected)!.dead, true);
assert.equal(player(after, f.ixians).leaders.find(l => l.id === f.trainer)!.dead, false);
assert.deepEqual(after.leaderSkills!.assignments.find(a => a.owner === f.ixians),
pending.leaderSkills!.assignments.find(a => a.owner === f.ixians));
assert.equal(after.leaderSkills!.deck.includes('warmaster'), false);
custody(after, f.afterSetup);
});
void test('native bot Face Dance uses original physical reserve/board sources after genuine skilled winner aftermath', () => {
const f = createPairedIxNexusSkillsFixture({ program: 'faceDance', skill: 'suk-graduate' });
const pending = finishPairedIxNexusSkillsBattle(revealPairedIxNexusSkillsBattle(f, { cunning: true }), 'faceDance');
const view = viewGame(pending, f.tleilaxu); view.players.find(p => p.id === f.tleilaxu)!.bot = 'Easy';
const action = botActions(view).find(a => a.type === 'decision'); assert.ok(action); assert.equal(action.reveal, true);
const after = stepPairedIxNexusSkills(pending, { actor: f.tleilaxu, action });
assert.equal(player(after, f.ixians).forces[f.location] ?? 0, 0);
assert.equal(player(after, f.tleilaxu).forces[f.location], player(pending, f.ixians).forces[f.location]);
assert.equal(after.leaderSkills!.assignments.some(a => a.leader === f.trainer), false);
custody(after, f.afterSetup);
});
void test('authenticated original starting hands/offers continue without redeal; foreign leader assignment and absent requested skill are rejected', () => {
const lobby = createGame('PAIREDIXSKILLSAUTH', newPlayer('http-ix', 'Ix', 'ixians'), true, ['ix']);
for (const [id, faction] of [['http-tl', 'tleilaxu'], ['http-guild', 'guild'], ['http-emperor', 'emperor']] as const)
joinGame(lobby, newPlayer(id, faction, faction));
const fresh = createPairedIxNexusSkillsFixture({ initial: lobby, skill: 'warmaster' });
const saved = structuredClone(fresh.offered), owner = player(saved, fresh.ixians);
assert.ok(owner.hand.length > 0); assert.ok(Object.keys(saved.leaderSkills!.offers).length > 0);
const offer = saved.leaderSkills!.offers[fresh.ixians], foreign = player(saved, fresh.opponent).leaders[0];
reject(saved, fresh.ixians, { type: 'leaderSkill', event: offer.event, skill: 'warmaster', leader: foreign.id });
const unavailable = LEADER_SKILL_CARDS.find(c => !offer.cards.includes(c.id))!.id;
assert.throws(() => createPairedIxNexusSkillsFixture({ initial: saved, skill: unavailable }));
const continued = createPairedIxNexusSkillsFixture({ initial: saved, skill: 'warmaster' });
assert.deepEqual(continued.initial, saved);
assert.deepEqual(continued.afterSetup.players.map(p => [p.id, p.hand]), saved.players.map(p => [p.id, p.hand]));
assert.deepEqual(continued.offered.leaderSkills!.offers, saved.leaderSkills!.offers);
assert.equal(continued.ixians, 'http-ix'); assert.equal(continued.tleilaxu, 'http-tl');
assert.equal(continued.afterNexusDraw.nexusCards!.cards!.hands['http-ix'], 'ixians');
assert.equal(continued.afterNexusDraw.nexusCards!.cards!.hands['http-tl'], 'tleilaxu');
custody(continued.afterNexusDraw, continued.afterSetup);
});