Files
Dune/tests/fixture-independent-skills-battle.ts
Quintine b9330d489e Compose independent Skills with original Tech and Stronghold modules
Unify fresh/live classic and native phase admission, retaining original counts, earned custody, separate income ledgers and physical card/skill continuations. Plain paired E3 phases no longer require cards or Nexus. Coupled44-file471/471, types/lint/build, genuine programmes and390px normal entry pass. Controlled card inputs explicit; no save retrofit or deployment.
2026-10-17 22:12:44 +11:00

523 lines
29 KiB
TypeScript

import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game, type Player,
} from '../game/engine';
import { territory } from '../game/board';
import { treacheryDeck, type Card } from '../game/cards';
import type { FactionId } from '../game/catalog';
import { ecazTreacheryCards } from '../game/ecaz-cards';
import { HARASS_WITHDRAW_CARD, type HarassWithdrawSelection } from '../game/harass-withdraw';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import type { SukRescueOption } from '../game/suk-graduate';
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
import { validateLeaderSkills } from '../game/leader-skills';
import { traitorDeck } from '../game/traitors';
import { quoteBattleResolution, type ResolutionCombatant } from '../game/battle-resolution-quote';
import { usesNativeBattleSpecial } from '../game/battle-card-slots';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
import { createTechTokens } from '../game/tech-tokens';
import { createStrongholdCards, strongholdBenefit } from '../game/stronghold-cards';
export type IndependentSkillsBattleStep = { actor: string; action: Action };
/** 'same-plan' trains the opposing Warmaster; 'traitor-cancel' adds its real
* Traitor call; 'suk' trains the owner's Suk Graduate for a real rescue choice. */
export type IndependentSkillsBattleCase = 'same-plan' | 'traitor-cancel' | 'suk';
export type IndependentSkillsBattleSkillSet = {
owner: LeaderSkillId;
opponent: LeaderSkillId;
observer: LeaderSkillId;
};
export type IndependentSkillsBattleOptions = {
/** Continue only the original fresh classic lobby built by this fixture. */
initial?: Game;
advanced?: boolean;
/** Select original unused phase modules before admission; both default off. */
tech?: boolean;
strongholds?: boolean;
seatIds?: readonly string[];
case?: IndependentSkillsBattleCase;
/** Construction hook: request exact original skill cards for one seat. */
skills?: Partial<IndependentSkillsBattleSkillSet>;
};
export type IndependentSkillsBattleSetup = {
initial: Game;
offered: Game;
afterSetup: Game;
owner: string;
opponent: string;
observer: string;
skills: IndependentSkillsBattleSkillSet;
trainers: Record<'owner' | 'opponent' | 'observer', string>;
actions: IndependentSkillsBattleStep[];
staging: string[];
};
export type IndependentSkillsBattleFixture = IndependentSkillsBattleSetup & {
case: IndependentSkillsBattleCase;
advanced: boolean;
location: string;
first: string;
second: string;
/** Present only when a genuine turn-one occupation earned the Arrakeen card. */
afterFirstMentat?: Game;
beforeShipment: Game;
shipment: IndependentSkillsBattleStep;
afterShipment: Game;
move: IndependentSkillsBattleStep;
afterMove: Game;
opponentMove: IndependentSkillsBattleStep;
beforeBattle: Game;
battleStep: IndependentSkillsBattleStep;
beforePlans: Game;
/** Revealed window: both original plans sealed, postures committed. */
game: Game;
plans: IndependentSkillsBattleStep[];
returns: HarassWithdrawSelection;
/** Real posture decision per seat id: true hides the skilled leader behind the shield. */
hide: Record<string, boolean>;
/** Attacker leader disc the opposing Traitor matches, for the control case. */
traitor: string | null;
};
export const independentSkillsBattlePlayer = (game: Game, actor: string): Player => {
const player = game.players.find((seat) => seat.id === actor);
assert.ok(player, `Missing original classic seat ${actor}`);
return player;
};
export const independentSkillsBattleClean = (game: Game) =>
!game.response && !game.phaseOpening && !game.decision && !game.pendingTreacheryDiscard;
const sum = (values: Record<string, number>) => Object.values(values).reduce((a, b) => a + b, 0);
/** Conserved original thirty-six cards, all fourteen skills and ordinary counters. */
export function assertIndependentSkillsBattleCustody(game: Game): void {
validateLeaderSkills(game.leaderSkills!, game.players);
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.hand.length <= (player.faction === 'harkonnen' ? 8 : 4), player.faction);
for (const [key, count] of Object.entries(player.forces)) {
assert.ok(!key.includes('undefined') && !key.startsWith('homeworld:'), key);
assert.ok(Number.isInteger(count) && count >= 0, key);
}
}
const cards = [...game.deck, ...game.discard, ...game.players.flatMap((p) => p.hand),
...(game.auction?.cards.slice(game.auction.index) ?? [])];
assert.equal(cards.length, 36);
assert.deepEqual(cards.map((card) => card.id).sort(),
[...treacheryDeck([]), ...ecazTreacheryCards()].map((card) => card.id).sort());
assert.deepEqual([...(game.traitorReserve ?? []), ...game.players.flatMap((p) => p.traitors)].sort(),
traitorDeck(game.players).sort());
}
/** Actual Fisher-Yates draws for one desired order; never a held-card injection. */
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;
}
/** Only the original skill lottery is solved; later native shuffles keep real draws. */
function skillLottery<T>(draws: readonly number[], run: () => T): 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 < draws.length ? draws[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');
}
}
function defaultSkills(caseKind: IndependentSkillsBattleCase): IndependentSkillsBattleSkillSet {
return caseKind === 'suk'
? { owner: 'suk-graduate', opponent: 'master-of-assassins', observer: 'rihani-decipherer' }
: { owner: 'prana-bindu-adept', opponent: 'warmaster', observer: 'rihani-decipherer' };
}
/** Private two-card offers are dealt in player order, so the first two positions
* of the solved deck decide the requesting seat's real choice. */
function skillDeckOrder(skills: IndependentSkillsBattleSkillSet): LeaderSkillId[] {
const requested = [skills.owner, skills.opponent, skills.observer];
const rest = LEADER_SKILL_CARDS.map((card) => card.id).filter((id) => !requested.includes(id));
return requested.flatMap((id) => [id, rest.shift()!]).concat(rest);
}
/** Genuine classic all-fourteen training from a fresh READY lobby whose independent
* three-card marker is staged only after every ready action. */
export function createIndependentSkillsBattleSetup(
options: IndependentSkillsBattleOptions = {},
): IndependentSkillsBattleSetup {
const caseKind = options.case ?? 'same-plan';
const ids = options.seatIds ?? ['emperor', 'guild', 'harkonnen'];
assert.equal(ids.length, 3);
const factions: FactionId[] = ['emperor', 'guild', 'harkonnen'];
let game = options.initial ? structuredClone(options.initial)
: createGame('INDEPENDENTSKILLSBATTLE', newPlayer(ids[0], 'Emperor', factions[0]), options.advanced ?? false, []);
if (!options.initial) for (let index = 1; index < ids.length; index++)
joinGame(game, newPlayer(ids[index], ids[index], factions[index]));
assert.equal(game.advanced, options.advanced ?? game.advanced);
assert.deepEqual(game.expansions, []);
assert.ok(!game.leaderSkills && !game.homeworlds && !game.nexusCards && !game.discoveryEnabled,
'Only the original classic phase modules are accepted.');
assert.ok(!options.strongholds || game.advanced, 'Original Stronghold Cards require Advanced');
if (options.tech && !game.techTokens) game.techTokens = createTechTokens();
if (options.strongholds && !game.strongholdCards) game.strongholdCards = createStrongholdCards();
assert.equal(game.status, 'lobby', 'Continue only an original fresh classic lobby.');
const staging: string[] = [];
const actions: IndependentSkillsBattleStep[] = [];
const owner = game.players[0].id, opponent = game.players[1].id, observer = game.players[2].id;
const skills = { ...defaultSkills(caseKind), ...options.skills };
const deckOrder = skillDeckOrder(skills);
assert.equal(deckOrder.length, LEADER_SKILL_CARDS.length);
const initial = structuredClone(game);
for (const player of game.players) if (!player.ready) {
const action: Action = { type: 'ready' };
actions.push({ actor: player.id, action });
game = applyAction(game, player.id, action);
}
game.ecazTreachery = true;
staging.push('Ready actions precede the independent three-card marker: the original lobby rejects ecazTreachery before every seat is ready.');
const draws = permutationDraws(LEADER_SKILL_CARDS.map((card) => card.id), deckOrder);
game = skillLottery(draws, () => initializeLeaderSkillsGameForAudit(game));
staging.push('Solved the original fourteen-card skill lottery with real Fisher-Yates draws; every later single-word draw in the same initializer kept a fixed maximum value, and larger native requests were forwarded unchanged.');
assert.equal(game.status, 'setup');
assert.equal(game.turn, 1);
assert.equal(game.phase, 0);
const offered = structuredClone(game);
const requested: Record<string, LeaderSkillId> = {
[owner]: skills.owner, [opponent]: skills.opponent, [observer]: skills.observer,
};
for (let count = 0; game.status === 'setup' && count < 200; count++) {
let step: IndependentSkillsBattleStep;
if (game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0];
const offer = game.leaderSkills!.offers[actor];
const view = viewGame(game, actor).leaderSkills!;
const wanted = requested[actor];
assert.ok(offer.cards.includes(wanted), 'The requested original skill must be in the real offer');
assert.ok(!view.unavailableSkills?.[wanted], 'The requested original skill must be selectable');
const leader = independentSkillsBattlePlayer(game, actor).leaders
.filter((disc) => view.eligibleLeaders.some((eligible) => eligible.id === disc.id))
.sort((a, b) => b.strength - a.strength || a.id.localeCompare(b.id))[0];
assert.ok(leader);
step = { actor, action: { type: 'leaderSkill', event: offer.event, skill: wanted, leader: leader.id } };
} else {
const next = nextStrongholdFactionsNativeStep(game);
assert.ok(next, 'The original setup continuation stopped before training completed');
step = next;
}
actions.push(structuredClone(step));
game = applyAction(game, step.actor, step.action);
}
assert.equal(game.status, 'playing');
assertIndependentSkillsBattleCustody(game);
const trained = (id: string) => game.leaderSkills!.assignments.find((a) => a.owner === id)!.leader;
for (const [actor, skill] of Object.entries(requested))
assert.equal(game.leaderSkills!.assignments.find((a) => a.owner === actor)!.skill, skill);
return { initial, offered, afterSetup: structuredClone(game), owner, opponent, observer, skills,
trainers: { owner: trained(owner), opponent: trained(opponent), observer: trained(observer) },
actions, staging };
}
export type IndependentSkillsBattlePolicy = {
hide?: Record<string, boolean>;
/** Real Guild choice of the Advanced movement slot: 'last' defers to the end. */
guildTiming?: 'first' | 'last';
};
/** Only genuine native decisions; a human plan, Traitor call, withdrawal allocation
* or Suk rescue choice is never invented by this helper. */
export function nextIndependentSkillsBattleStep(
game: Game,
policy: IndependentSkillsBattlePolicy = {},
): IndependentSkillsBattleStep | 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] ?? false,
} };
if (decision?.kind === 'guildTiming')
return { actor: decision.player, action: { type: 'decision', take: (policy.guildTiming ?? 'last') === 'first' } };
if (decision?.kind === 'mentatQuestion')
return { actor: decision.player, action: { type: 'decision', event: decision.event, decline: true } };
if (decision?.kind === 'sukRescue' || decision?.kind === 'harassWithdraw') return null;
if (decision?.kind === 'rihani')
return { actor: decision.player, action: { type: 'decision', event: decision.event, draw: false } };
}
return nextStrongholdFactionsNativeStep(game);
}
export function advanceIndependentSkillsBattle(game: Game, until: (state: Game) => boolean,
actions?: IndependentSkillsBattleStep[], policy: IndependentSkillsBattlePolicy = {}): Game {
for (let count = 0; count < 1500; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const step = nextIndependentSkillsBattleStep(game, policy);
assert.ok(step, 'Supply the genuine human plan, allocation or rescue choice');
actions?.push(structuredClone(step));
game = applyAction(game, step.actor, step.action);
}
throw Error('The original classic all-fourteen programme did not reach its boundary');
}
/** Conserved physical card custody: remaining deck or discard → a real hand. */
function hold(game: Game, actor: string, matches: (card: Card) => boolean, staging: string[]): Card {
const player = independentSkillsBattlePlayer(game, actor);
const held = player.hand.find(matches);
if (held) return held;
const from = game.deck.findIndex(matches);
assert.ok(from >= 0 || game.discard.some(matches), 'The required original card must remain in deck or discard custody');
const card = from >= 0 ? game.deck.splice(from, 1)[0] : game.discard.splice(game.discard.findIndex(matches), 1)[0];
assert.ok(player.hand.length < (player.faction === 'harkonnen' ? 8 : 4));
player.hand.push(card);
staging.push(`Conserved original ${card.id}: ${from >= 0 ? 'undealt deck' : 'discard'} → ${actor}'s hand`);
return card;
}
/** Conserved Traitor identity: only a real reserved or held leader exchanges. */
function conserveTraitor(game: Game, actor: string, identity: string, staging: string[]): void {
const player = independentSkillsBattlePlayer(game, actor);
if (player.traitors.includes(identity)) return;
const returned = player.traitors[0];
assert.ok(returned);
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}`);
}
function strongestOther(game: Game, actor: string, excluded: string): string {
const leader = independentSkillsBattlePlayer(game, actor).leaders
.filter((disc) => !disc.dead && !disc.usedAt && disc.id !== excluded)
.sort((a, b) => b.strength - a.strength || a.id.localeCompare(b.id))[0];
assert.ok(leader);
return leader.id;
}
/** Real training, a paid native shipment and both original Battle Plans. With
* Strongholds, that shipment earns Arrakeen at genuine first END Mentat and a
* paid turn-two Guild shipment challenges it; otherwise the original Pasty Mesa
* split movement remains unchanged. Only card and Traitor custody is staged. */
export function createIndependentSkillsBattleFixture(
options: IndependentSkillsBattleOptions = {},
): IndependentSkillsBattleFixture {
const caseKind = options.case ?? 'same-plan';
const setup = createIndependentSkillsBattleSetup(options);
const advanced = setup.afterSetup.advanced;
const staging = [...setup.staging];
const actions = [...setup.actions];
const { owner, opponent, trainers } = setup;
const strongholds = !!setup.afterSetup.strongholdCards;
const location = strongholds ? 'arrakeen' : 'pasty_mesa';
const first = `${location}:${strongholds ? 10 : 5}`;
const second = strongholds ? first : 'pasty_mesa:6';
if (!strongholds) assert.deepEqual(territory(location).sectors, [5, 6, 7, 8]);
let game = advanceIndependentSkillsBattle(setup.afterSetup,
(state) => state.phase === 5 && state.active === owner && independentSkillsBattleClean(state), actions);
const beforeShipment = structuredClone(game);
assert.equal(independentSkillsBattlePlayer(game, owner).reserves, 20);
const shipment: IndependentSkillsBattleStep = { actor: owner, action: {
type: 'ship', territory: location, sector: strongholds ? 10 : 5, amount: 4, elite: 0, allyPayment: 0 } };
actions.push(structuredClone(shipment));
game = applyAction(game, owner, shipment.action);
game = advanceIndependentSkillsBattle(game, (state) => independentSkillsBattleClean(state) &&
!state.pendingShipment && state.phase === 5 && state.active === owner, actions);
const afterShipment = structuredClone(game);
assert.equal(independentSkillsBattlePlayer(game, owner).reserves, 16);
assert.equal(independentSkillsBattlePlayer(game, owner).forces[first], 4);
staging.push(`The Emperor paid its original ${strongholds ? 'four-spice stronghold' : 'eight-spice desert'} shipment of four reserves; the real payment reached the Guild.`);
const move: IndependentSkillsBattleStep = { actor: owner, action: strongholds
? { type: 'endMovement' }
: { type: 'move', from: first, amount: 2, territory: location, sector: 6 } };
actions.push(structuredClone(move));
game = applyAction(game, owner, move.action);
let afterFirstMentat: Game | undefined;
if (strongholds) {
game = advanceIndependentSkillsBattle(game, state => state.turn === 2 && state.phase === 0, actions);
afterFirstMentat = structuredClone(game);
assert.equal(game.strongholdCards!.claimedTurn, 1);
assert.equal(game.strongholdCards!.owners.arrakeen, owner);
staging.push('The four originally shipped Emperor counters held empty Arrakeen through genuine first END Mentat and earned its card; no owner or army was assigned.');
game = advanceIndependentSkillsBattle(game, state => state.turn === 2 && state.phase === 5 &&
state.active === owner && independentSkillsBattleClean(state), actions);
} else {
game = advanceIndependentSkillsBattle(game, (state) => independentSkillsBattleClean(state) &&
state.phase === 5 && state.active === owner, actions);
assert.deepEqual(independentSkillsBattlePlayer(game, owner).forces, { [first]: 2, [second]: 2 });
staging.push('The Emperor moved two originally shipped counters to sector 6, leaving two physical Harass return locations.');
}
const afterMove = structuredClone(game);
const endMove: IndependentSkillsBattleStep = { actor: owner, action: { type: 'endMovement' } };
actions.push(structuredClone(endMove));
game = applyAction(game, owner, endMove.action);
game = advanceIndependentSkillsBattle(game, (state) => independentSkillsBattleClean(state) &&
state.phase === 5 && state.active === opponent, actions);
const opponentMove: IndependentSkillsBattleStep = { actor: opponent, action: strongholds
? { type: 'ship', territory: location, sector: 10, amount: 5, elite: 0 }
: { type: 'move', from: 'tueks_sietch:5', amount: 5, territory: location, sector: 6 } };
actions.push(structuredClone(opponentMove));
game = applyAction(game, opponent, opponentMove.action);
game = advanceIndependentSkillsBattle(game, (state) => independentSkillsBattleClean(state) &&
!state.pendingShipment && state.phase === 5 && state.active === opponent, actions);
assert.equal(independentSkillsBattlePlayer(game, opponent).forces[second], 5);
staging.push(`The Guild declined its first Advanced movement slot and ${strongholds ? 'paid to ship five original reserves into the held Arrakeen' : 'marched its original five-counter army into Pasty Mesa'}.`);
const endOpponent: IndependentSkillsBattleStep = { actor: opponent, action: { type: 'endMovement' } };
actions.push(structuredClone(endOpponent));
game = applyAction(game, opponent, endOpponent.action);
game = advanceIndependentSkillsBattle(game, (state) => state.phase === 6 && independentSkillsBattleClean(state), actions);
if (strongholds) {
const amount = independentSkillsBattlePlayer(game, owner).spice;
if (amount) {
const bribe: IndependentSkillsBattleStep = { actor: owner,
action: { type: 'bribe', target: setup.observer, amount } };
actions.push(structuredClone(bribe));
game = applyAction(game, owner, bribe.action);
}
staging.push('The Emperor actually bribed its entire remaining wallet to the observer before plans; the held Arrakeen contribution is support budget, never personal spice.');
}
for (const player of game.players) game.deck.push(...player.hand.splice(0));
staging.push('At the real Battle window every original hand returned to the undealt deck before the physical Reinforcements and Harass & Withdraw cards were conserved to the Emperor.');
hold(game, owner, (card) => card.id === REINFORCEMENTS_CARD, staging);
hold(game, owner, (card) => card.id === HARASS_WITHDRAW_CARD, staging);
const worthless = caseKind === 'suk' && !strongholds ? null : hold(game, opponent, (card) => card.kind === 'worthless', staging);
const hide: Record<string, boolean> = { [owner]: true, [opponent]: caseKind !== 'suk' };
// The trained leader must be committed (so Warmaster/Suk take effect) exactly
// when the owner's posture decision hides it behind the shield.
const ownerLeader = caseKind === 'suk' ? trainers.owner : strongestOther(game, owner, trainers.owner);
const opponentLeader = caseKind === 'suk' ? strongestOther(game, opponent, trainers.opponent) : trainers.opponent;
let traitor: string | null = null;
if (caseKind === 'traitor-cancel') {
// The opposing Traitor must match the attacker's real committed leader disc.
conserveTraitor(game, opponent, ownerLeader, staging);
traitor = ownerLeader;
}
const beforeBattle = structuredClone(game);
const choice = viewGame(game, game.active!).battleChoices.find((candidate) => candidate.territory === location);
assert.ok(choice);
if (!strongholds) {
assert.equal(choice.attacker, owner);
assert.equal(choice.chooser, owner);
}
assert.ok(choice.chooser);
const battleStep: IndependentSkillsBattleStep = { actor: choice.chooser, action: {
type: 'chooseBattle', territory: location,
target: choice.chooser === choice.attacker ? choice.defender : choice.attacker } };
actions.push(structuredClone(battleStep));
game = applyAction(game, choice.chooser, battleStep.action);
game = advanceIndependentSkillsBattle(game,
(state) => !!state.battle && !state.battle.revealed && independentSkillsBattleClean(state),
actions, { hide });
const beforePlans = structuredClone(game);
const support = advanced ? 2 : 0;
const plans: IndependentSkillsBattleStep[] = [
{ actor: owner, action: { type: 'battlePlan', dial: 2, support, leader: ownerLeader,
weapon: REINFORCEMENTS_CARD, defense: HARASS_WITHDRAW_CARD } },
{ actor: opponent, action: { type: 'battlePlan', dial: 0, support: 0, leader: opponentLeader,
...(worthless ? { weapon: worthless.id } : {}) } },
];
for (const plan of plans) {
actions.push(structuredClone(plan));
game = applyAction(game, plan.actor, plan.action);
}
assert.ok(game.battle?.revealed);
staging.push(`Both combatants made their real posture decisions before sealing: the Emperor hid its skilled leader behind the shield, the Guild ${caseKind === 'suk' ? 'kept its skilled leader face up and therefore committed another leader' : 'hid its skilled leader so that the committed Warmaster could resolve'}.`);
const returns: HarassWithdrawSelection = strongholds
? { [first]: { normal: 2, elite: 0 } }
: { [first]: { normal: 1, elite: 0 }, [second]: { normal: 1, elite: 0 } };
const fixture: IndependentSkillsBattleFixture = { ...setup, case: caseKind, advanced, location, first, second,
afterFirstMentat, beforeShipment, shipment, afterShipment, move, afterMove, opponentMove, beforeBattle, battleStep,
beforePlans, game, plans, returns, hide, traitor, actions, staging };
assertIndependentSkillsBattleCustody(game);
return fixture;
}
/** Quote the real revealed plans, postures and physical custody; Traitor calls and
* the typed withdrawal allocation are the caller's current human choices. */
export function quoteIndependentSkillsBattle(game: Game,
options: { callers?: readonly string[]; returns?: HarassWithdrawSelection } = {}) {
const battle = game.battle;
assert.ok(battle?.revealed);
const side = (actor: string): ResolutionCombatant => {
const player = independentSkillsBattlePlayer(game, actor);
const view = viewGame(game, actor).battle!;
return { id: actor, faction: player.faction, spice: player.spice, hand: player.hand,
stronghold: game.advanced ? strongholdBenefit(game.strongholdCards, actor, battle.territory, battle.strongholdCopy) : null,
plan: battle.plans[actor],
leader: player.leaders.find((leader) => leader.id === battle.plans[actor].leader),
forces: view.ownForces!,
leaderSkills: game.leaderSkills!.assignments.filter((assignment) => assignment.owner === actor)
.map((assignment) => ({ skill: assignment.skill, leader: assignment.leader,
faceUp: battle.leaderSkillHidden?.[actor] !== true,
captured: false })),
...(view.reinforcements && usesNativeBattleSpecial(battle.plans[actor], REINFORCEMENTS_CARD)
? { reinforcementsReserves: { normal: player.reserves - (player.elites?.reserves ?? 0),
elite: player.elites?.reserves ?? 0 } } : {}),
...(view.harassWithdraw && usesNativeBattleSpecial(battle.plans[actor], HARASS_WITHDRAW_CARD) ? { harassWithdraw: { blocked: null,
forces: view.harassWithdraw.forces, locations: view.harassWithdraw.locations,
...(view.harassWithdraw.occupy ? { occupy: view.harassWithdraw.occupy } : {}) },
...(options.returns ? { harassSelection: options.returns } : {}) } : {}) };
};
return quoteBattleResolution({
advanced: game.advanced,
turn: game.turn,
territory: battle.territory,
aggressor: viewGame(game, game.host).battle!.aggressor,
attacker: side(battle.attacker),
defender: side(battle.defender),
voters: viewGame(game, game.host).battle!.traitorVoters.map((id) => ({ id, beneficiary: id,
called: options.callers?.includes(id) ?? false,
traitors: independentSkillsBattlePlayer(game, id).traitors })),
participants: game.players,
physicalCards: [...game.deck, ...game.discard, ...game.players.flatMap((player) => player.hand)],
pendingAuditorPresent: false,
pendingRetentionPresent: false,
});
}
/** Resolve the real aftermath: Traitor calls, the typed withdrawal allocation, the
* Suk rescue choice and mandatory disposal, stopping at the original Collection. */
export function finishIndependentSkillsBattle(game: Game,
options: { callers?: readonly string[]; return?: HarassWithdrawSelection;
rescue?: (options: readonly SukRescueOption[]) => number;
actions?: IndependentSkillsBattleStep[]; until?: (state: Game) => boolean } = {}): Game {
const until = options.until ?? ((state: Game) =>
state.phase === 7 && independentSkillsBattleClean(state));
for (let count = 0; count < 400; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
let step: IndependentSkillsBattleStep | null;
if (game.decision?.kind === 'harassWithdraw') {
assert.ok(options.return, 'Supply the genuine typed undialed return allocation');
step = { actor: game.decision.player, action: { type: 'decision', event: game.decision.event,
returns: options.return } };
} else if (game.decision?.kind === 'sukRescue') {
const decision = game.decision;
const choice = options.rescue ? options.rescue(decision.options)
: decision.options.findIndex((option) => option.normal + option.elite === 2 && 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 = nextIndependentSkillsBattleStep(game);
step = next && next.action.type === 'traitorCall'
? { ...next, action: { type: 'traitorCall', call: options.callers?.includes(next.actor) ?? false } }
: next;
}
assert.ok(step, 'Resolve both original plans before their physical aftermath');
options.actions?.push(structuredClone(step));
game = applyAction(game, step.actor, step.action);
}
throw Error('The original classic battle aftermath did not reach Collection');
}