import assert from 'node:assert/strict'; import { applyAction, handLimit, viewGame, type Action, type Game, type Player } from '../game/engine'; import { treacheryDeck } from '../game/cards'; import { ecazTreacheryCards } from '../game/ecaz-cards'; import { withoutEcazLoyalty } from '../game/ecaz-loyalty'; import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards'; import { validateLeaderSkills } from '../game/leader-skills'; import { traitorDeck } from '../game/traitors'; import { defaultHarassWithdrawAllocation, HARASS_WITHDRAW_CARD, type HarassWithdrawSelection, } from '../game/harass-withdraw'; import { REINFORCEMENTS_CARD } from '../game/reinforcements'; import type { SukRescueOption } from '../game/suk-graduate'; import { quoteBattleResolution, type ResolutionCombatant } from '../game/battle-resolution-quote'; import { homeworldGameIntegrity } from '../game/homeworld-game'; import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions'; import { chooseEcazOccupyLead, finishEcazOccupySetup, stageEcazOccupyCard } from './fixture-ecaz-occupy'; import { createHomeworldEcazOccupyFixture } from './fixture-homeworld-ecaz-occupy'; /** Printed E3 shared rule (docs/ECAZ_TREACHERY_RULES.md): the three-card variant * is independent of faction selection and other variants, so standalone Ecaz * and the native Ecaz/Moritani pair keep it; all14 Leader Skills is the separate * optional module documented in docs/LEADER_SKILLS_RULES.md. Card timings and the * fee-before-return order stay provisional as recorded there, not new rulings. */ export type EcazIndependentSkillsStep = { actor: string; action: Action }; export type EcazIndependentSkillsCard = 'reinforcements' | 'harass'; export type EcazIndependentSkillsOptions = { advanced?: boolean; /** Native Ecaz/Moritani seats; the default keeps the standalone Emperor ally. */ paired?: boolean; /** All14 skills; Basic and Advanced are both genuine native bands. */ skills?: boolean; /** The independent physical three-card variant; `false` is the printed no-card control. */ independent?: boolean; /** Original native World setup, selected before start; off by default. */ homeworlds?: boolean; /** Original optional modules, both off by default; Strongholds require Advanced. */ tech?: boolean; strongholds?: boolean; /** Real native Battle Plan cards in the lead's own plan. */ cards?: readonly EcazIndependentSkillsCard[]; lead?: 'ecaz' | 'ally'; /** Trained posture: hide the Ecaz skill for the skilled band, reveal for normal. */ hide?: boolean; /** The opponent holds the real Traitor matching the committed lead disc and calls it. */ traitor?: boolean; }; export type EcazIndependentSkillsPolicy = { hide?: Record }; export const ECAZ_INDEPENDENT_SKILLS_CARD_IDS = { reinforcements: REINFORCEMENTS_CARD, harass: HARASS_WITHDRAW_CARD, } as const; /** Reinforcements occupies the weapon slot, Harass & Withdraw the defense slot. */ export const ECAZ_INDEPENDENT_SKILLS_SLOTS = { reinforcements: 'weapon', harass: 'defense', } as const; export const ecazIndependentSkillsPlayer = (game: Game, actor: string): Player => { const player = game.players.find(p => p.id === actor); assert.ok(player, `Missing original seat ${actor}`); return player; }; export const ecazIndependentSkillsClean = (game: Game) => !game.response && !game.phaseOpening && !game.decision && !game.pendingTreacheryDiscard; /** Scoped deterministic entropy for this bounded programme, following the * existing independent-skills fixtures: the fourteen-card lottery is solved with * real Fisher-Yates draws, later single-word draws in the same creation keep the * fixed maximum value, larger native requests are forwarded, and the patch is * restored even when a native action rejects. */ function entropy(run: () => T, rolls: readonly number[]): T { const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues'); const native = crypto.getRandomValues.bind(crypto); let cursor = 0; crypto.getRandomValues = (array: V): V => { if (array instanceof Uint32Array && array.length === 1) array[0] = cursor < rolls.length ? rolls[cursor++] : 0xffffffff; else Reflect.apply(native, crypto, [array]); return array; }; try { return run(); } finally { if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor); else Reflect.deleteProperty(crypto, 'getRandomValues'); } } /** Actual Fisher-Yates draws for one desired fourteen-card order. */ function permutationDraws(source: readonly T[], desired: readonly T[]): number[] { const working = [...source], draws: number[] = []; for (let index = working.length - 1; index > 0; index--) { const from = working.indexOf(desired[index]); assert.ok(from >= 0 && from <= index); draws.push(Math.floor(((from + 0.5) / (index + 1)) * 4294967296)); [working[index], working[from]] = [working[from], working[index]]; } assert.deepEqual(working, desired); return draws; } export function stepEcazIndependentSkills(game: Game, step: EcazIndependentSkillsStep): Game { return applyAction(game, step.actor, step.action); } /** Real native decisions only; a human plan, withdrawal allocation or rescue * choice is never invented by this helper. */ export function nextEcazIndependentSkillsStep(game: Game, policy: EcazIndependentSkillsPolicy = {}): EcazIndependentSkillsStep | null { if (!game.response && !game.phaseOpening) { const decision = game.decision; if (decision?.kind === 'leaderSkillVisibility') return { actor: decision.player, action: { type: 'leaderSkillVisibility', event: decision.event, hide: policy.hide?.[decision.player] ?? true } }; if (decision?.kind === 'guildTiming') return { actor: decision.player, action: { type: 'decision', take: false } }; if (decision?.kind === 'mentatQuestion') return { actor: decision.player, action: { type: 'decision', event: decision.event, decline: true } }; if (decision?.kind === 'rihani') return { actor: decision.player, action: { type: 'decision', event: decision.event, draw: false } }; if (decision?.kind === 'faceDance') return { actor: decision.player, action: { type: 'decision', reveal: false } }; if (decision?.kind === 'techToken') return { actor: decision.player, action: { type: 'decision', token: decision.choices[0] } }; if (['sukRescue', 'harassWithdraw', 'battleLosses'].includes(decision?.kind ?? '')) return null; } return nextStrongholdFactionsNativeStep(game); } export function advanceEcazIndependentSkills(state: Game, until: (game: Game) => boolean, actions: EcazIndependentSkillsStep[] = [], policy: EcazIndependentSkillsPolicy = {}): Game { let game = state; for (let count = 0; count < 1500; count++) { if (until(game)) return game; assert.notEqual(game.status, 'finished'); const step = nextEcazIndependentSkillsStep(game, policy); assert.ok(step, 'Supply the genuine private plan, typed return or rescue choice'); actions.push(structuredClone(step)); game = stepEcazIndependentSkills(game, step); } throw Error('The native Ecaz independent programme did not reach its boundary'); } /** Only conserved physical card custody moves; no stock, forces or wallet. */ export function stageEcazIndependentSkillsCard(game: Game, actor: string, id: string, staging: string[]): string { const staged = stageEcazOccupyCard(game, actor, card => card.id === id); staging.push(`Conserved ${staged}: original deck/discard/hand → ${actor} hand; not a purchase, native deal or a new physical card`); return staged; } /** The admitted original card source (thirty-six with the independent variant), * all14 skills and ordinary counters. */ export function assertEcazIndependentSkillsCustody(game: Game): void { if (game.homeworlds) homeworldGameIntegrity(game); if (game.leaderSkills) validateLeaderSkills(game.leaderSkills, game.players); const sum = (values: Record) => Object.values(values).reduce((a, b) => a + b, 0); for (const player of game.players) { assert.equal(player.reserves + player.tanks + sum(player.forces), 20, player.faction); assert.ok([player.reserves, player.tanks, ...Object.values(player.forces)] .every(count => Number.isSafeInteger(count) && count >= 0), player.faction); if (player.elites) { assert.equal(player.elites.reserves + player.elites.tanks + sum(player.elites.forces), player.faction === 'emperor' ? 5 : 3, player.faction); assert.ok([player.elites.reserves, player.elites.tanks, ...Object.values(player.elites.forces)] .every(count => Number.isSafeInteger(count) && count >= 0), player.faction); assert.ok(player.elites.reserves <= player.reserves && player.elites.tanks <= player.tanks); for (const [key, count] of Object.entries(player.elites.forces)) assert.ok(count <= (player.forces[key] ?? 0)); } assert.ok(player.hand.length <= handLimit(player), player.faction); } const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand), ...(game.ixSetupCards ?? []), ...(game.auction?.cards.slice(game.auction.index) ?? [])]; assert.deepEqual(cards.map(card => card.id).sort(), [...treacheryDeck(game.expansions), ...(game.ecazTreachery ? ecazTreacheryCards() : [])].map(card => card.id).sort()); assert.deepEqual([...(game.traitorReserve ?? []), ...game.players.flatMap(p => p.traitors), ...game.players.flatMap(p => (p.faceDancers ?? []).map(dancer => dancer.leader))].sort(), withoutEcazLoyalty(traitorDeck(game.players, game.expansions.includes('ix')), game.ecazLoyalty).sort()); } export type EcazIndependentSkillsFixture = { /** Original fresh lobby/setup snapshots taken by the shared genuine programme. */ initial: Game; afterSetup: Game; /** Real turn-one Mentat acquisition, before the worm alliance and battle. */ afterFirstMentat?: Game; strongholdTerritory?: string; /** Original before-lead window and the recorded native lead choice. */ beforeLead: Game; leadStep: EcazIndependentSkillsStep; afterLead: Game; /** Clean before-plan window (both plans still unsealed) and the real plans. */ beforePlans: Game; plans: EcazIndependentSkillsStep[]; /** Revealed window: both original plans sealed, postures committed. */ game: Game; /** Typed physical allocation derived from the real projected offer. */ returns: HarassWithdrawSelection; ecaz: string; ally: string; opponent: string; territory: string; location: string; fixedEcazDial: number; planDial: number; support: number; cardUser: string; cardIds: string[]; cardKinds: EcazIndependentSkillsCard[]; /** Ecaz's real trained disc; null when the no-Skills control is used. */ trainer: string | null; /** Conserved opposing Traitor identity and its actual caller seats. */ traitor: string | null; callers: string[]; advanced: boolean; skills: boolean; independent: boolean; hide: boolean; actions: EcazIndependentSkillsStep[]; staging: string[]; }; /** Original offered card and eligible disc for every seat; Ecaz requests its * trained Suk Graduate. The captured `initial` snapshot stays untouched. */ function trainEcazSeat(initial: Game, steps: EcazIndependentSkillsStep[]): Game { let game = initial; while (game.status === 'setup' && game.setupStage === 'leaderSkills') { const actor = Object.keys(game.leaderSkills!.offers)[0]; const offer = game.leaderSkills!.offers[actor]; const view = viewGame(game, actor).leaderSkills!; const skill: LeaderSkillId = actor === 'ecaz' ? 'suk-graduate' : offer.cards.find(card => !view.unavailableSkills?.[card])!; assert.ok(offer.cards.includes(skill), 'The requested original skill must be in the real offer'); assert.ok(!view.unavailableSkills?.[skill], 'The requested original skill must be selectable'); const eligible = new Set(view.eligibleLeaders.map(leader => leader.id)); const leader = ecazIndependentSkillsPlayer(game, actor).leaders .filter(disc => eligible.has(disc.id)).sort((a, b) => b.strength - a.strength)[0]; assert.ok(leader, 'The real eligible leaders must supply this seat’s choice'); const step: EcazIndependentSkillsStep = { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: leader.id } }; steps.push(structuredClone(step)); game = stepEcazIndependentSkills(game, step); } return finishEcazOccupySetup(game); } /** Conserved original Traitor identity: one real held card exchanges with the * opponent's reserve copy so the printed call has genuine custody. */ function conserveTraitor(game: Game, actor: string, identity: string, staging: string[]): void { const player = ecazIndependentSkillsPlayer(game, actor); if (player.traitors.includes(identity)) return; const returned = player.traitors[0]; assert.ok(returned, 'The opponent needs an original Traitor Card to exchange'); const reserve = game.traitorReserve?.indexOf(identity) ?? -1; const donor = game.players.find(seat => seat.id !== actor && seat.traitors.includes(identity)); assert.ok(reserve >= 0 || donor, 'The requested Traitor must retain original custody'); if (reserve >= 0) game.traitorReserve!.splice(reserve, 1, returned); else donor!.traitors[donor!.traitors.indexOf(identity)] = returned; player.traitors[0] = identity; staging.push(`Conserved Traitor ${identity}: original ${donor?.id ?? 'reserve'} custody exchanged with ${actor}'s ${returned}; no new Traitor Card`); } /** Real living disc for a sealed plan; a revealed skilled leader has to stay * behind the shield, so it is never offered here. */ function battleLeader(game: Game, seat: string, exclude: string | null, strongest: boolean): string { const discs = ecazIndependentSkillsPlayer(game, seat).leaders .filter(disc => !disc.dead && !disc.usedAt && disc.id !== exclude); assert.ok(discs.length, `The ${seat} roster must supply a legal living leader`); return discs.sort((a, b) => (strongest ? b.strength - a.strength : a.strength - b.strength) || a.id.localeCompare(b.id))[0].id; } /** Genuine native setup, a real worm Nexus alliance, paid Arrakis shipments and * the original lead/plan windows. With skills the all14 lottery is solved with * real Fisher-Yates draws so Ecaz’s own offer holds its Suk Graduate, while the * remaining single-word draws in this one creation keep the fixed maximum value * and larger native requests are forwarded unchanged. */ export function createEcazIndependentSkillsFixture( options: EcazIndependentSkillsOptions = {}, ): EcazIndependentSkillsFixture { const advanced = options.advanced ?? true; const skills = options.skills ?? true; const independent = options.independent ?? true; const hide = options.hide ?? true; const lead = options.lead ?? 'ecaz'; const cardKinds = [...(options.cards ?? ['reinforcements', 'harass'] as const)]; const picks: EcazIndependentSkillsStep[] = []; const staging: string[] = []; const order = ['suk-graduate' as LeaderSkillId, ...LEADER_SKILL_CARDS.map(card => card.id).filter(id => id !== 'suk-graduate')]; const draws = skills ? permutationDraws(LEADER_SKILL_CARDS.map(card => card.id), order) : []; const shared = entropy(() => createHomeworldEcazOccupyFixture({ advanced, skills, independent, homeworlds: options.homeworlds ?? false, tech: options.tech ?? false, strongholds: options.strongholds ?? false, ...(options.paired ? { allyFaction: 'moritani' as const } : {}), ...(skills ? { finishSetup: (initial: Game) => trainEcazSeat(initial, picks) } : {}), }), draws); if (skills) { staging.push('Solved the original fourteen-card lottery with real Fisher-Yates draws for Ecaz’s own offer; later single-word draws in this creation kept the fixed maximum value and larger native requests were forwarded unchanged.'); assert.equal(shared.initial.leaderSkills !== undefined, true); const ecazAssignment = shared.afterSetup.leaderSkills!.assignments.find(a => a.owner === shared.ecaz); assert.equal(ecazAssignment?.skill, 'suk-graduate', 'The requested original skill must be the real assignment of that seat'); } else { assert.equal(shared.initial.leaderSkills, undefined); assert.equal(shared.afterSetup.leaderSkills, undefined); } assert.equal(shared.initial.ecazTreachery === true, independent, 'The independent three-card variant must be an original lobby selection'); const trainer = skills ? shared.afterSetup.leaderSkills!.assignments.find(a => a.owner === shared.ecaz)!.leader : null; const beforeLead = structuredClone(shared.game); assert.equal(beforeLead.decision?.kind, 'ecazBattleLead', 'The printed Ecaz Occupy lead choice must be the genuine pending decision'); const leadStep: EcazIndependentSkillsStep = { actor: shared.ecaz, action: { type: 'decision', event: beforeLead.decision!.event, lead: lead === 'ecaz' ? shared.ecaz : shared.ally } }; const actions: EcazIndependentSkillsStep[] = [...shared.actions, ...picks]; let game = chooseEcazOccupyLead({ ...shared, game: beforeLead }, lead); actions.push(structuredClone(leadStep)); const policy: EcazIndependentSkillsPolicy = { hide: { [shared.ecaz]: hide, [shared.ally]: true, [shared.opponent]: true } }; game = advanceEcazIndependentSkills(game, state => !!state.battle && ecazIndependentSkillsClean(state) && !state.battle.preparation && state.battle.preLeader?.closed !== false, actions, policy); const afterLead = structuredClone(game); const cardUser = lead === 'ecaz' ? shared.ecaz : shared.ally; const cardIds = cardKinds.map(kind => stageEcazIndependentSkillsCard(game, cardUser, ECAZ_INDEPENDENT_SKILLS_CARD_IDS[kind], staging)); const profile = viewGame(game, cardUser).battle?.ecazOccupy?.profile; assert.ok(profile, 'The selected coalition must quote its real Occupy profile'); assert.equal(profile.lead, cardUser); const own: { weapon?: string; defense?: string } = {}; for (const [index, kind] of cardKinds.entries()) own[ECAZ_INDEPENDENT_SKILLS_SLOTS[kind]] = cardIds[index]; const variable = 2; const support = variable; const allyTrainer = game.leaderSkills?.assignments.find(a => a.owner === shared.ally)?.leader; const planLeader = cardUser === shared.ecaz ? (skills && hide ? trainer! : battleLeader(game, shared.ecaz, trainer, true)) : battleLeader(game, shared.ally, allyTrainer ?? null, true); const defender = battleLeader(game, shared.opponent, null, false); const traitor = options.traitor ? planLeader : null; const callers: string[] = []; if (traitor) { conserveTraitor(game, shared.opponent, traitor, staging); callers.push(shared.opponent); } const feeSources = cardKinds.includes('reinforcements') && game.homeworlds ? viewGame(game, cardUser).battle?.reinforcements?.homeworldSources?.[0] : undefined; if (cardKinds.includes('reinforcements') && game.homeworlds) assert.ok(feeSources, 'Choose an actual offered native World source for the card user’s fee'); const plans: EcazIndependentSkillsStep[] = [ { actor: cardUser, action: { type: 'battlePlan', dial: profile.fixedEcazDial + variable, support, allyPayment: 0, leader: planLeader, ...own, ...(feeSources ? { reinforcementsSources: structuredClone(feeSources) } : {}) } }, { actor: shared.opponent, action: { type: 'battlePlan', dial: 0, support: 0, leader: defender, weapon: null, defense: null } }, ]; const beforePlans = structuredClone(game); for (const plan of plans) { actions.push(structuredClone(plan)); game = stepEcazIndependentSkills(game, plan); } assert.ok(game.battle?.revealed, 'Both original plans must be revealed'); // A sole legal allocation is applied by the engine; otherwise the card user // still holds a genuine typed sector choice, derived from the real offer. let returns: HarassWithdrawSelection = {}; if (game.decision?.kind === 'harassWithdraw') { const offer = viewGame(game, game.decision.player).battle?.harassAllocation; assert.ok(offer, 'Use only the actual revealed physical allocation offer'); returns = defaultHarassWithdrawAllocation(offer.context, offer.dial, offer.support); } const fixture: EcazIndependentSkillsFixture = { initial: shared.initial, afterSetup: shared.afterSetup, afterFirstMentat: shared.afterFirstMentat, strongholdTerritory: shared.strongholdTerritory, beforeLead, leadStep, afterLead, beforePlans, plans, game, returns, ecaz: shared.ecaz, ally: shared.ally, opponent: shared.opponent, territory: shared.territory, location: shared.location, fixedEcazDial: profile.fixedEcazDial, planDial: profile.fixedEcazDial + variable, support, cardUser, cardIds, cardKinds, trainer, traitor, callers, advanced, skills, independent, hide, actions, staging, }; assertEcazIndependentSkillsCustody(game); return fixture; } /** Real revealed quote: the card user's own reserve fee, the coalition scores * and the typed own-army withdrawal groups, never a merged army. */ export function quoteEcazIndependentSkillsBattle(f: EcazIndependentSkillsFixture, state?: Game) { const game = state ?? f.game; const battle = game.battle; assert.ok(battle?.revealed, 'Quote only the genuinely revealed original battle'); const view = viewGame(game, f.cardUser).battle!; const seat = (actor: string): ResolutionCombatant => { const player = ecazIndependentSkillsPlayer(game, actor); const own = viewGame(game, actor).battle!; const plan = battle.plans[actor]; assert.ok(plan, `The ${actor} plan must be sealed`); return { id: actor, faction: player.faction, spice: player.spice, hand: player.hand, plan, leader: player.leaders.find(leader => leader.id === plan.leader), forces: own.ownForces!, stronghold: own.strongholdEffects[actor], leaderSkills: (game.leaderSkills?.assignments ?? []).filter(a => a.owner === actor) .map(a => ({ skill: a.skill, leader: a.leader, faceUp: battle.leaderSkillHidden?.[actor] !== true, captured: false })), ...(own.reinforcements ? { reinforcementsReserves: { normal: player.reserves - (player.elites?.reserves ?? 0), elite: player.elites?.reserves ?? 0 } } : {}), ...(own.harassWithdraw ? { harassWithdraw: { blocked: null, forces: own.harassWithdraw.forces, locations: own.harassWithdraw.locations, ...(own.harassWithdraw.occupy ? { occupy: own.harassWithdraw.occupy } : {}) }, ...(Object.keys(f.returns).length ? { harassSelection: f.returns } : {}) } : {}) }; }; assert.ok(view.ecazOccupy?.profile, 'Quote the actual selected native coalition'); return quoteBattleResolution({ advanced: game.advanced, turn: game.turn, territory: battle.territory, aggressor: viewGame(game, game.host).battle!.aggressor, ecazOccupy: view.ecazOccupy.profile, attacker: seat(battle.attacker), defender: seat(battle.defender), voters: viewGame(game, game.host).battle!.traitorVoters.map(id => ({ id, beneficiary: battle.ecazOccupy?.lead && [f.ecaz, f.ally].includes(id) ? battle.ecazOccupy.lead : id, called: false, traitors: ecazIndependentSkillsPlayer(game, id).traitors })), participants: game.players, physicalCards: [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand)], pendingAuditorPresent: false, pendingRetentionPresent: false, }); } /** Real lead choice for a custom programme; the recorded step stays legal. */ export function chooseEcazIndependentSkillsLead(game: Game, lead: 'ecaz' | 'ally', ecaz: string, ally: string): EcazIndependentSkillsStep { const decision = game.decision; assert.ok(decision?.kind === 'ecazBattleLead', 'Only the native Ecaz lead choice may select this plan owner'); return { actor: ecaz, action: { type: 'decision', event: decision.event, lead: lead === 'ecaz' ? ecaz : ally } }; } /** Resolve the original aftermath: Traitor calls, the typed withdrawal * allocation, the real casualty choices, the Suk rescue and mandatory disposal, * stopping at native Spice Collection. */ export function settleEcazIndependentSkillsBattle(f: EcazIndependentSkillsFixture, options: { state?: Game; returns?: HarassWithdrawSelection; rescue?: (options: readonly SukRescueOption[]) => number; stop?: 'rescue' | 'returns' | 'collection' } = {}): Game { let game = structuredClone(options.state ?? f.game); for (let count = 0; count < 400; count++) { if (options.stop === 'rescue' && game.decision?.kind === 'sukRescue') return game; if (options.stop === 'returns' && game.decision?.kind === 'harassWithdraw') return game; if (game.phase === 7 && ecazIndependentSkillsClean(game)) return game; assert.notEqual(game.status, 'finished'); let step: EcazIndependentSkillsStep | null; const decision = game.decision; if (decision?.kind === 'harassWithdraw') { const returns = options.returns ?? f.returns; const offer = viewGame(game, decision.player).battle?.harassAllocation; assert.ok(offer, 'Use only the actual revealed physical allocation offer'); step = { actor: decision.player, action: { type: 'decision', event: decision.event, returns } }; } else if (decision?.kind === 'battleLosses') { const index = decision.options.findIndex(option => option.normal === f.planDial - f.fixedEcazDial && option.elite === 0); step = { actor: decision.player, action: { type: 'decision', choice: index >= 0 ? index : 0 } }; } else if (decision?.kind === 'sukRescue') { const choice = options.rescue ? options.rescue(decision.options) : decision.options.findIndex(option => option.normal === f.fixedEcazDial && option.elite === 0 && option.kept); assert.ok(choice >= 0, 'Choose one real Suk Graduate rescue option'); step = { actor: decision.player, action: { type: 'decision', event: decision.event, choice } }; } else { const next = nextEcazIndependentSkillsStep(game); step = next && next.action.type === 'traitorCall' ? { ...next, action: { type: 'traitorCall', call: f.callers.includes(next.actor) } } : next; } assert.ok(step, 'Resolve both original plans before their physical aftermath'); game = stepEcazIndependentSkills(game, step); } throw Error('The native Ecaz independent battle did not reach Collection'); }