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(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 = (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['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 }; }