Files
Dune/tests/fixture-moritani-nexus-skills-modules.ts

342 lines
25 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict';
import {
applyAction, createGame, handLimit, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, viewGame,
type Action, type Game,
} from '../game/engine';
import type { FactionId } from '../game/catalog';
import type { Card } from '../game/cards';
import { leaders, treacheryDeck } from '../game/cards';
import { territory } from '../game/board';
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
import { validateLeaderSkills } from '../game/leader-skills';
import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards';
import { TERROR_KINDS, type TerrorKind } from '../game/moritani-terror';
import { nextNexusSkillsModulesBattleStep } from './fixture-nexus-skills-modules-battles';
import {
moritaniSkillsModulesClean, moritaniSkillsModulesPlayer, moritaniSkillsModulesPolicy,
nextMoritaniSkillsModulesStep, revealMoritaniSkillsModulesPlans, stageMoritaniSkillsModulesBattle,
} from './fixture-moritani-skills-modules';
import { holdMoritaniStrongholdsTraitor } from './fixture-moritani-strongholds';
import type { StrongholdFactionsNativeStep } from './fixture-stronghold-factions';
export type MoritaniNexusSkillsModulesStep = StrongholdFactionsNativeStep;
export type MoritaniNexusSkillsModulesOptions = {
/** Original lobby or unassigned turn-one skill setup, never a converted save. */
initial?: Game;
rules?: 'basic' | 'advanced';
tech?: boolean;
strongholds?: boolean;
factions?: readonly FactionId[];
seatIds?: readonly string[];
/** Supplied offers are authoritative; an explicit request must actually be offered. */
guildSkill?: LeaderSkillId;
moritaniSkill?: LeaderSkillId;
holder?: 'moritani' | 'guild';
normalCall?: boolean;
stack?: boolean;
};
export type MoritaniNexusSkillsModulesFixture = {
initial: Game; afterSetup: Game; afterFirstStorm: Game;
beforeFirstMentat: Game; firstMentatStep: MoritaniNexusSkillsModulesStep; afterFirstMentat: Game;
beforeAlliance: Game; allianceActions: MoritaniNexusSkillsModulesStep[];
beforeClosingDraw: Game; closingDrawStep: MoritaniNexusSkillsModulesStep; afterClosingDraw: Game;
beforeBattle: Game; battleAction: MoritaniNexusSkillsModulesStep;
plans: Game; planActions: MoritaniNexusSkillsModulesStep[]; revealed: Game; pending: Game; game: Game;
moritani: string; opponent: string; partner: string; holder: string;
target: string; moritaniLeader: string; opponentLeader: string;
moritaniCards: string[]; opponentCards: string[]; karama: string;
actions: MoritaniNexusSkillsModulesStep[]; staging: string[];
};
export const moritaniNexusSkillsModulesPlayer = moritaniSkillsModulesPlayer;
export const moritaniNexusSkillsModulesPolicy = moritaniSkillsModulesPolicy;
export const moritaniNexusSkillsModulesClean = moritaniSkillsModulesClean;
const snapshot = (game: Game): Game => structuredClone(game);
/** Only the original physical all14 shuffle is controlled, before Traitor deals.
* The remainder of the native initializer retains its original randomness. */
function originalSkillShuffle<T>(selected: LeaderSkillId[], initialize: () => T): 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 = 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 (cursor < rolls.length && array instanceof Uint32Array && array.length === 1) array[0] = rolls[cursor++];
else Reflect.apply(native, crypto, [array]);
return array;
};
try { return initialize(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
export function initializeMoritaniNexusSkillsModulesOffers(options: MoritaniNexusSkillsModulesOptions = {}): Game {
const advanced = options.rules ? options.rules === 'advanced' : options.initial?.advanced ?? true;
const tech = options.tech ?? !!options.initial?.techTokens;
const strongholds = options.strongholds ?? !!options.initial?.strongholdCards;
const factions: readonly FactionId[] = options.factions ?? ['moritani', 'guild', 'emperor'];
let game = options.initial ? snapshot(options.initial)
: createGame('MORITANINEXUSSKILLSMODULES', newPlayer(options.seatIds?.[0] ?? factions[0], factions[0], factions[0]), advanced, ['ecaz']);
if (!options.initial) for (const faction of factions.slice(1))
joinGame(game, newPlayer(options.seatIds?.[game.players.length] ?? faction, faction, faction));
assert.equal(game.advanced, advanced); assert.deepEqual(game.expansions, ['ecaz']);
assert.ok(!game.homeworlds && game.players.length >= (tech ? 3 : 2) && game.players.length <= 6);
assert.equal(game.players.filter(p => p.faction === 'moritani').length, 1);
assert.equal(game.players.filter(p => p.faction === 'guild').length, 1);
const allowed: FactionId[] = ['moritani', 'guild', 'emperor', 'atreides', 'fremen', 'beneGesserit', ...(advanced ? [] : ['harkonnen'] as FactionId[])];
assert.ok(game.players.every(p => allowed.includes(p.faction))); assert.ok(advanced || !strongholds);
assert.ok(game.status === 'lobby' || game.status === 'setup' && game.turn === 1 && game.phase === 0 &&
(game.setupStage === 'prediction' || game.setupStage === 'leaderSkills') && game.leaderSkills?.assignments.length === 0,
'Only an original lobby or undealt skill-first setup may continue');
if (game.status === 'setup') {
assert.equal(!!game.techTokens, tech); assert.equal(!!game.strongholdCards, strongholds);
assertMoritaniNexusSkillsModulesCustody(game);
while (game.setupStage === 'prediction') game = stepMoritaniNexusSkillsModules(game);
return game;
}
if (!!game.techTokens !== tech) game = applyAction(game, game.host, { type: 'techTokens', enabled: tech });
if (!!game.strongholdCards !== strongholds) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: strongholds });
game.nexusCards ??= { cards: null, phase: null };
assert.deepEqual(game.nexusCards, { cards: null, phase: null }); assert.ok(!game.leaderSkills);
for (const p of game.players) if (!p.ready) game = applyAction(game, p.id, { type: 'ready' });
const guildSkill = options.guildSkill ?? 'suk-graduate', moritaniSkill = options.moritaniSkill ?? 'warmaster';
const passive: LeaderSkillId[] = ['sandmaster', 'killer-medic', 'prana-bindu-adept', 'master-of-assassins', 'swordmaster-of-ginaz', 'planetologist']
.filter(c => c !== guildSkill && c !== moritaniSkill) as LeaderSkillId[];
const selected = game.players.map(p => p.faction === 'guild' ? guildSkill : p.faction === 'moritani' ? moritaniSkill : passive.shift()!);
game = originalSkillShuffle(selected, () => initializeLeaderSkillsGameForAudit(game));
while (game.setupStage === 'prediction') game = stepMoritaniNexusSkillsModules(game);
return game;
}
export function nextMoritaniNexusSkillsModulesStep(game: Game): MoritaniNexusSkillsModulesStep | null {
if (game.response || game.phaseOpening || game.pendingTreacheryDiscard) return nextMoritaniSkillsModulesStep(game);
const d = game.decision;
if (d?.kind === 'moritaniPlacement' || d?.kind === 'moritaniAssassinate' || d?.kind === 'moritaniSetup' || d?.kind === 'leaderSkillVisibility')
return nextMoritaniSkillsModulesStep(game);
if (d?.kind === 'moritaniTerror' && ['select', 'offer'].includes(game.pendingTerrorEntry!.stage))
return { actor: d.player, action: { type: 'decision', decline: true } };
return nextNexusSkillsModulesBattleStep(game);
}
export function stepMoritaniNexusSkillsModules(game: Game, next = nextMoritaniNexusSkillsModulesStep(game), actions?: MoritaniNexusSkillsModulesStep[]): Game {
assert.ok(next, 'An original human sealed Battle Plan is required'); actions?.push(structuredClone(next));
return applyAction(game, next.actor, next.action);
}
export function advanceMoritaniNexusSkillsModules(game: Game, until: (state: Game) => boolean, actions?: MoritaniNexusSkillsModulesStep[]): Game {
for (let n = 0; n < 2000; n++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished'); game = stepMoritaniNexusSkillsModules(game, undefined, actions);
}
throw Error('Original Moritani Skills/Nexus continuation did not reach its requested boundary');
}
export function completeMoritaniNexusSkillsModulesSetup(game: Game, options: MoritaniNexusSkillsModulesOptions = {}, actions?: MoritaniNexusSkillsModulesStep[]): Game {
for (let n = 0; game.status === 'setup' && n < 240; n++) {
if (game.setupStage !== 'leaderSkills') { game = stepMoritaniNexusSkillsModules(game, undefined, actions); continue; }
const actor = Object.keys(game.leaderSkills!.offers)[0], p = moritaniSkillsModulesPlayer(game, actor);
const own = viewGame(game, actor).leaderSkills!, offer = own.offer!;
const requested = p.faction === 'guild' ? options.guildSkill : p.faction === 'moritani' ? options.moritaniSkill : undefined;
const skill = requested ?? offer.cards.find(c => !own.unavailableSkills?.[c]);
assert.ok(skill && offer.cards.includes(skill) && !own.unavailableSkills?.[skill], 'Requested skill must be genuinely offered');
const leader = p.faction === 'guild' && skill === 'suk-graduate'
? own.eligibleLeaders.find(l => l.name === 'Master Bewt') : own.eligibleLeaders[0];
assert.ok(leader);
game = stepMoritaniNexusSkillsModules(game, { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: leader.id } }, actions);
}
assert.equal(game.status, 'playing'); return game;
}
export function assertMoritaniNexusSkillsModulesCustody(game: Game): void {
validateLeaderSkills(game.leaderSkills!, game.players); validateNexusCards(game.nexusCards!.cards!, game.players);
assert.deepEqual([...game.leaderSkills!.deck, ...Object.values(game.leaderSkills!.offers).flatMap(o => o.cards),
...game.leaderSkills!.assignments.map(a => a.skill)].sort(), LEADER_SKILL_CARDS.map(c => c.id).sort());
const nexus = game.nexusCards!.cards!;
assert.deepEqual([...nexus.deck, ...nexus.discard, ...Object.values(nexus.hands).filter((c): c is FactionId => c !== null)].sort(), [...NEXUS_FACTIONS].sort());
assert.deepEqual([...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? [])].map(c => c.id).sort(), treacheryDeck(['ecaz']).map(c => c.id).sort());
for (const p of game.players) {
assert.deepEqual(p.leaders.map(l => l.id).sort(), leaders(p.faction).map(l => l.id).sort());
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0), 20);
if (p.elites) {
assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((a, b) => a + b, 0), p.faction === 'emperor' ? 5 : 3);
for (const [key, count] of Object.entries(p.elites.forces)) assert.ok(count <= (p.forces[key] ?? 0));
}
}
if (game.moritaniTerror) {
assert.deepEqual(game.moritaniTerror.tokens.map(t => t.kind).sort(), [...TERROR_KINDS].sort());
assert.equal(new Set(game.moritaniTerror.tokens.map(t => t.id)).size, 6);
}
}
function reserveBoard(game: Game, staging: string[]) {
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 through their own reserves; wallets, phase, turn, skill effects and earned module custody unchanged.');
}
function place(game: Game, actor: string, site: string, count: number, staging: string[]) {
const p = moritaniSkillsModulesPlayer(game, actor); assert.ok(p.reserves - (p.elites?.reserves ?? 0) >= count);
p.reserves -= count; const key = `${site}:${territory(site).sectors[0]}`; p.forces[key] = (p.forces[key] ?? 0) + count;
staging.push(`Conserved ${count} original ordinary ${actor} counters: own reserves → ${key}.`);
}
export function holdMoritaniNexusSkillsModulesCard(game: Game, actor: string, kind: Card['kind'] | 'karama', staging: string[] = [], excluded: string[] = []): string {
const p = moritaniSkillsModulesPlayer(game, actor);
const matches = (c: Card) => (c.kind === kind || c.effect === kind) && !excluded.includes(c.id);
const held = p.hand.find(matches); if (held) return held.id;
const index = game.deck.findIndex(matches);
const donor = index < 0 ? game.players.find(p => p.id !== actor && p.hand.some(matches)) : undefined;
assert.ok((index >= 0 || donor) && p.hand.length < handLimit(p));
const card = index >= 0 ? game.deck.splice(index, 1)[0] : donor!.hand.splice(donor!.hand.findIndex(matches), 1)[0];
p.hand.push(card);
staging.push(`Conserved original ecaz33 ${card.id}: ${donor?.id ?? 'unused deck'} → ${actor}'s hand; not a natural deal.`); return card.id;
}
function orderSpice(game: Game, kinds: ('land' | 'worm')[], staging: string[]) {
const front = kinds.map(kind => {
const index = game.spiceDeck.findIndex(c => kind === 'land' ? 'territory' in c : 'worm' in c); assert.ok(index >= 0);
return game.spiceDeck.splice(index, 1)[0];
});
game.spiceDeck.unshift(...front); staging.push(`Conserved original undealt Spice order: ${kinds.join(', ')}; original Discards unchanged.`);
}
export function resolveMoritaniNexusSkillsModulesBattle(game: Game, moritani: string, normalCall = false, actions?: MoritaniNexusSkillsModulesStep[]): Game {
for (let n = 0; n < 160; n++) {
if (game.decision?.kind === 'sukRescue' || game.decision?.kind === 'moritaniAssassinate' ||
!game.battle && moritaniSkillsModulesClean(game) && !game.pendingTreacheryDiscard) return game;
const next = nextMoritaniNexusSkillsModulesStep(game); assert.ok(next);
if (next.action.type === 'traitorCall') next.action.call = normalCall && next.actor === moritani;
game = stepMoritaniNexusSkillsModules(game, next, actions);
}
throw Error('Original battle did not reach rescue, assassination or settled boundary');
}
export type MoritaniNexusSkillsModulesSettlementBoundary = {
game: Game;
kind: 'losses' | 'rescue' | 'assassination' | 'cards' | 'tech' | 'settled';
};
/** Human physical casualty/rescue/assassination/cleanup/reward choices are never
* skipped to manufacture a later boundary. Apply that actual choice, then resume. */
export function settleMoritaniNexusSkillsModulesBoundary(game: Game, moritani: string, normalCall = false,
actions?: MoritaniNexusSkillsModulesStep[]): MoritaniNexusSkillsModulesSettlementBoundary {
const choices: Partial<Record<NonNullable<Game['decision']>['kind'], MoritaniNexusSkillsModulesSettlementBoundary['kind']>> = {
battleLosses: 'losses', sukRescue: 'rescue', moritaniAssassinate: 'assassination', battleCards: 'cards', techToken: 'tech',
};
for (let n = 0; n < 160; n++) {
const kind = game.decision && choices[game.decision.kind];
if (kind && !game.response && !game.phaseOpening) return { game, kind };
if (!game.battle && moritaniSkillsModulesClean(game) && !game.pendingTreacheryDiscard) return { game, kind: 'settled' };
const next = nextMoritaniNexusSkillsModulesStep(game); assert.ok(next);
if (next.action.type === 'traitorCall') next.action.call = normalCall && next.actor === moritani;
game = stepMoritaniNexusSkillsModules(game, next, actions);
}
throw Error('Original battle settlement did not reach its next physical human choice');
}
export function finishMoritaniNexusSkillsModulesBattle(game: Game, actions?: MoritaniNexusSkillsModulesStep[]): Game {
return advanceMoritaniNexusSkillsModules(game, s => !s.battle && moritaniSkillsModulesClean(s) && !s.pendingTreacheryDiscard, actions);
}
export const revealMoritaniNexusSkillsModulesPlans = revealMoritaniSkillsModulesPlans;
export const stageMoritaniNexusSkillsModulesBattle = stageMoritaniSkillsModulesBattle;
export function moritaniNexusSkillsModulesToken(game: Game, kind: TerrorKind) {
const token = game.moritaniTerror!.tokens.find(t => t.kind === kind); assert.ok(token); return token;
}
export function moritaniNexusSkillsModulesRequest(game: Game, actor: string, kind: TerrorKind = 'robbery', site = 'red_chasm'): Action {
const offer = viewGame(game, actor).nexusMoritani; assert.ok(offer);
return { type: 'decision', token: moritaniNexusSkillsModulesToken(game, kind).id, territory: site, nexus: offer.event };
}
/** Only pass the persisted native response queue, not a guessed policy actor. */
export function allowMoritaniNexusSkillsModules(game: Game, actions?: MoritaniNexusSkillsModulesStep[]): Game {
for (let n = 0; (game.response || game.phaseOpening || game.pendingKarama) && n < 100; n++)
game = stepMoritaniNexusSkillsModules(game, undefined, actions);
assert.ok(!game.response && !game.phaseOpening && !game.pendingKarama); return game;
}
export function cancelMoritaniNexusSkillsModules(game: Game, actor: string, card: string, actions?: MoritaniNexusSkillsModulesStep[]): Game {
game = stepMoritaniNexusSkillsModules(game, { actor, action: { type: 'card', mode: 'cancel', card } }, actions);
return allowMoritaniNexusSkillsModules(game, actions);
}
export function rejectMoritaniNexusSkillsModulesAction(game: Game, actor: string, action: Action): void {
const before = snapshot(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
}
/** Source: docs/NEXUS_MORITANI_RULES.md13–32 (publisher face and native rulebook).
* Genuine first Storm/END Mentat → worm/alliance/closing draw → original battle
* → actual second Mentat supply opportunity. Enhanced relocation, HMS and
* Grumman are deliberately NOT interpreted by this continuation. */
export function createMoritaniNexusSkillsModulesFixture(options: MoritaniNexusSkillsModulesOptions = {}): MoritaniNexusSkillsModulesFixture {
const actions: MoritaniNexusSkillsModulesStep[] = [], staging: string[] = [];
let game = initializeMoritaniNexusSkillsModulesOffers(options); const initial = snapshot(game);
game = completeMoritaniNexusSkillsModulesSetup(game, options, actions); const afterSetup = snapshot(game);
const moritani = game.players.find(p => p.faction === 'moritani')!.id;
const opponent = game.players.find(p => p.faction === 'guild')!.id;
const partner = game.players.find(p => p.id !== moritani && p.id !== opponent)?.id;
assert.ok(partner, 'A real unallied closing Nexus draw requires two other original alliance seats; two-seat setup is supported separately.');
while (game.phase === 0) {
const next = nextMoritaniNexusSkillsModulesStep(game); assert.ok(next);
if (next.action.type === 'stormDial') next.action.amount = 4;
game = stepMoritaniNexusSkillsModules(game, next, actions);
}
game = advanceMoritaniNexusSkillsModules(game, s => s.phase === 1 && moritaniSkillsModulesClean(s), actions);
const afterFirstStorm = snapshot(game); orderSpice(game, ['land', 'land'], staging);
game = advanceMoritaniNexusSkillsModules(game, s => s.decision?.kind === 'moritaniPlacement', actions);
if (options.stack) {
game = stepMoritaniNexusSkillsModules(game, { actor: moritani, action: { type: 'decision', token: moritaniNexusSkillsModulesToken(game, 'sabotage').id, territory: 'arrakeen' } }, actions);
game = allowMoritaniNexusSkillsModules(game, actions);
} else game = stepMoritaniNexusSkillsModules(game, undefined, actions);
reserveBoard(game, staging);
const holder = options.holder === 'moritani' ? moritani : opponent;
place(game, holder, 'tueks_sietch', 1, staging); place(game, holder === moritani ? opponent : moritani, 'carthag', 1, staging);
let beforeFirstMentat: Game | undefined, firstMentatStep: MoritaniNexusSkillsModulesStep | undefined;
while (game.turn === 1) {
const before = snapshot(game), next = nextMoritaniNexusSkillsModulesStep(game); assert.ok(next);
game = stepMoritaniNexusSkillsModules(game, next, actions);
if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = next; }
}
assert.ok(beforeFirstMentat && firstMentatStep); const afterFirstMentat = snapshot(game);
game = advanceMoritaniNexusSkillsModules(game, s => s.phase === 1 && moritaniSkillsModulesClean(s), actions);
orderSpice(game, ['worm', 'land', 'land'], staging);
game = advanceMoritaniNexusSkillsModules(game, s => s.nexus === true && !s.spiceWindow && !s.spiceResolution && moritaniSkillsModulesClean(s), actions);
const beforeAlliance = snapshot(game), allianceActions: MoritaniNexusSkillsModulesStep[] = [
{ actor: opponent, action: { type: 'alliance', target: partner } },
{ actor: partner, action: { type: 'alliance', target: opponent } },
];
for (const next of allianceActions) game = stepMoritaniNexusSkillsModules(game, next, actions);
game = advanceMoritaniNexusSkillsModules(game, s => s.nexusCards?.phase?.stage === 'drawing' && moritaniSkillsModulesClean(s), actions);
const cards = game.nexusCards!.cards!, index = cards.deck.indexOf('moritani'); assert.ok(index >= 0);
cards.deck.unshift(cards.deck.splice(index, 1)[0]); staging.push('Conserved undealt original Moritani Nexus singleton ordered before its real unallied closing draw.');
const beforeClosingDraw = snapshot(game), closingDrawStep: MoritaniNexusSkillsModulesStep = {
actor: moritani, action: { type: 'nexusCardChoice', turn: game.turn, card: cards.hands[moritani], choice: 'draw', ownRedraws: 0 },
};
game = stepMoritaniNexusSkillsModules(game, closingDrawStep, actions); const afterClosingDraw = snapshot(game);
game = advanceMoritaniNexusSkillsModules(game, s => s.phase === 5 && moritaniSkillsModulesClean(s), actions);
stageMoritaniSkillsModulesBattle(game, moritani, opponent, staging);
const trained = new Set(game.leaderSkills!.assignments.map(a => a.leader));
const ownLeader = moritaniSkillsModulesPlayer(game, moritani).leaders.filter(l => !l.dead && !trained.has(l.id)).sort((a, b) => a.strength - b.strength)[0];
const enemyLeader = moritaniSkillsModulesPlayer(game, opponent).leaders.filter(l => !l.dead && !trained.has(l.id)).sort((a, b) => b.strength - a.strength)[0];
const target = game.leaderSkills!.assignments.find(a => a.owner === opponent)!.leader; assert.ok(ownLeader && enemyLeader && enemyLeader.id !== target);
holdMoritaniStrongholdsTraitor(game, moritani, options.normalCall ? enemyLeader.id : target, staging);
const moritaniCards = [holdMoritaniNexusSkillsModulesCard(game, moritani, 'worthless', staging)];
moritaniCards.push(holdMoritaniNexusSkillsModulesCard(game, moritani, 'worthless', staging, moritaniCards));
const opponentCards = [holdMoritaniNexusSkillsModulesCard(game, opponent, 'worthless', staging)];
game = advanceMoritaniNexusSkillsModules(game, s => s.phase === 6 && moritaniSkillsModulesClean(s), actions);
const beforeBattle = snapshot(game), actor = game.active!; assert.ok([moritani, opponent].includes(actor));
const battleAction: MoritaniNexusSkillsModulesStep = { actor, action: { type: 'chooseBattle', territory: 'tueks_sietch', target: actor === moritani ? opponent : moritani } };
game = stepMoritaniNexusSkillsModules(game, battleAction, actions);
game = advanceMoritaniNexusSkillsModules(game, s => !!s.battle && !s.battle.revealed && !nextMoritaniNexusSkillsModulesStep(s), actions);
const planActions: MoritaniNexusSkillsModulesStep[] = [
{ actor: moritani, action: { type: 'battlePlan', leader: ownLeader.id, dial: 1, ...(game.advanced ? { support: 1 } : {}), weapon: moritaniCards[0], defense: moritaniCards[1] } },
{ actor: opponent, action: { type: 'battlePlan', leader: enemyLeader.id, dial: 3, ...(game.advanced ? { support: 3 } : {}), weapon: opponentCards[0] } },
];
const plans = snapshot(game), revealed = revealMoritaniSkillsModulesPlans(game, planActions, actions);
const pending = resolveMoritaniNexusSkillsModulesBattle(snapshot(revealed), moritani, options.normalCall, actions);
game = finishMoritaniNexusSkillsModulesBattle(snapshot(pending), actions);
game = advanceMoritaniNexusSkillsModules(game, s => s.decision?.kind === 'moritaniPlacement', actions);
const karama = holdMoritaniNexusSkillsModulesCard(game, opponent, 'karama', staging);
assertMoritaniNexusSkillsModulesCustody(game);
return { initial, afterSetup, afterFirstStorm, beforeFirstMentat, firstMentatStep, afterFirstMentat,
beforeAlliance, allianceActions, beforeClosingDraw, closingDrawStep, afterClosingDraw, beforeBattle,
battleAction, plans, planActions, revealed, pending, game, moritani, opponent, partner, holder,
target, moritaniLeader: ownLeader.id, opponentLeader: enemyLeader.id, moritaniCards, opponentCards,
karama, actions, staging };
}