Files
Dune/tests/fixture-ecaz-independent-skills.ts

456 lines
26 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, 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<string, boolean> };
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<T>(run: () => T, rolls: readonly number[]): T {
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] = 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<T>(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<string, number>) => 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');
}