193 lines
11 KiB
TypeScript
193 lines
11 KiB
TypeScript
import assert from 'node:assert/strict';
|
||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||
import { combatArmy } from '../game/combat-location';
|
||
import { nativeShipmentSources } from '../game/homeworld-options';
|
||
import type { NativeReserveSelections } from '../game/homeworld-native-reserves';
|
||
import type { HomeworldForces } from '../game/homeworld-custody';
|
||
import { createIndependentCardModulesFixture, type IndependentCardModulesOptions } from './fixture-independent-card-modules';
|
||
import { actWorldPhaseBattle, advanceWorldPhaseBattle } from './fixture-world-phase-battle';
|
||
import { harassHomeworldClean, nextHarassHomeworldStep } from './fixture-harass-homeworld';
|
||
import { takeHarassCard } from './fixture-harass-withdraw';
|
||
import {
|
||
advanceNativeWorldBattle, assertNativeWorldBattleInventory, conserveDancer, createNativeWorldBattleFixture,
|
||
HARASS_WITHDRAW_CARD, nativeWorldBattleFinished, nativeWorldBattlePlayer,
|
||
REINFORCEMENTS_CARD, type NativeWorldBattleFixture, type NativeWorldBattleStep,
|
||
} from './fixture-native-world-battle';
|
||
|
||
export type NativeWorldPhaseBattleOptions = {
|
||
advanced?: boolean;
|
||
tech?: boolean;
|
||
strongholds?: boolean;
|
||
skills?: boolean;
|
||
training?: IndependentCardModulesOptions['training'];
|
||
skilled?: boolean;
|
||
harass?: boolean;
|
||
/** Actual Cyborg support, never support for Reinforcements' score-only +2. */
|
||
support?: 0 | 1 | 3;
|
||
};
|
||
export type NativeWorldPhaseBattleFixture = {
|
||
initial: Game;
|
||
afterSetup: Game;
|
||
beforeFirstMentat: Game;
|
||
firstMentatStep: NativeWorldBattleStep;
|
||
afterFirstMentat: Game;
|
||
shipments: { before: Game; step: NativeWorldBattleStep; after: Game }[];
|
||
beforeBattle: Game;
|
||
battleStep: NativeWorldBattleStep;
|
||
/** Original prepared battle, no sealed plans. */
|
||
game: Game;
|
||
owner: string;
|
||
opponent: string;
|
||
observer: string;
|
||
location: string;
|
||
planActions: [NativeWorldBattleStep, NativeWorldBattleStep];
|
||
sources: NativeReserveSelections;
|
||
returns: Record<string, HomeworldForces>;
|
||
nativeSteps: NativeWorldBattleStep[];
|
||
staging: string[];
|
||
};
|
||
|
||
/** Arrakeen's held card applies only in Arrakeen, never on a foreign World.
|
||
* Ix therefore really pays to place 3 Suboids + 3 Cyborgs there on turn one,
|
||
* earns the card at original END Mentat, then meets a paid Guild attack on
|
||
* turn two. The three-force R fee still comes from original native Ix custody.
|
||
* A conserved matching Face Dancer offers its original optional continuation,
|
||
* exposing the settled support invoice before Collection. No army/wallet/phase/
|
||
* owner or module receipt is assigned.
|
||
*/
|
||
export function createNativeWorldPhaseBattleFixture(
|
||
options: NativeWorldPhaseBattleOptions = {},
|
||
): NativeWorldPhaseBattleFixture {
|
||
const advanced = options.advanced ?? true;
|
||
const setup = createIndependentCardModulesFixture({ advanced, homeworlds: true,
|
||
tech: options.tech ?? true, strongholds: options.strongholds ?? advanced,
|
||
skills: options.skills, training: options.training,
|
||
factions: ['ixians', 'tleilaxu', 'guild'], expansions: ['ix'] });
|
||
let game = setup.game;
|
||
const afterSetup = structuredClone(game), nativeSteps = [...setup.steps];
|
||
const owner = 'ixians', opponent = 'guild', observer = 'tleilaxu', location = 'arrakeen:10';
|
||
const staging = [
|
||
'Original setup and first-Storm entropy only are fixed; later Storm Cards and movement stay original.',
|
||
'Position eight original unopened non-worm Spice Cards for the first two native Spice Blows; no replacement face or phase assignment.',
|
||
];
|
||
for (let position = 0; position < 8; position++) {
|
||
const at = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
|
||
assert.ok(at >= position); game.spiceDeck.splice(position, 0, game.spiceDeck.splice(at, 1)[0]);
|
||
}
|
||
const act = (step: NativeWorldBattleStep) => {
|
||
game = actWorldPhaseBattle(game, step, nativeSteps); return game;
|
||
};
|
||
const reach = (until: (g: Game) => boolean) => { game = advanceWorldPhaseBattle(game, until, nativeSteps); };
|
||
const shipments: NativeWorldPhaseBattleFixture['shipments'] = [];
|
||
const ship = (actor: string, amount: number, elite: number) => {
|
||
const before = structuredClone(game), view = viewGame(game, actor);
|
||
const sources = nativeShipmentSources(view, amount, elite); assert.ok(sources);
|
||
const step: NativeWorldBattleStep = { actor, action: {
|
||
type: 'ship', territory: 'arrakeen', sector: 10, amount, elite, homeworldSources: sources, allyPayment: 0,
|
||
} };
|
||
act(step);
|
||
reach(g => harassHomeworldClean(g) && !g.pendingShipment && !g.pendingHomeworldShipment);
|
||
shipments.push({ before, step, after: structuredClone(game) });
|
||
};
|
||
reach(g => g.turn === 1 && g.phase === 5 && g.active === owner && harassHomeworldClean(g));
|
||
ship(owner, 6, 3);
|
||
assert.deepEqual(combatArmy(game, owner, 'arrakeen'), { normal: 3, elite: 3 });
|
||
reach(g => g.turn === 1 && g.phase === 8 && harassHomeworldClean(g));
|
||
let beforeFirstMentat: Game | undefined, firstMentatStep: NativeWorldBattleStep | undefined;
|
||
while (game.turn === 1) {
|
||
const step = nextHarassHomeworldStep(game); assert.ok(step);
|
||
const before = structuredClone(game), after = act(step);
|
||
if (after.turn === 2) { beforeFirstMentat = before; firstMentatStep = step; }
|
||
}
|
||
assert.ok(beforeFirstMentat && firstMentatStep);
|
||
const afterFirstMentat = structuredClone(game);
|
||
reach(g => g.turn === 2 && g.phase === 5 && harassHomeworldClean(g));
|
||
while (game.phase === 5) {
|
||
const actor = game.active!;
|
||
if (harassHomeworldClean(game) && !nativeWorldBattlePlayer(game, actor).shipped && actor === opponent)
|
||
ship(actor, 3, 0);
|
||
else {
|
||
const step = nextHarassHomeworldStep(game); assert.ok(step); act(step);
|
||
}
|
||
}
|
||
reach(g => g.phase === 6 && !!g.active && harassHomeworldClean(g));
|
||
for (const player of game.players) game.deck.push(...player.hand.splice(0));
|
||
takeHarassCard(game, owner, REINFORCEMENTS_CARD);
|
||
if (options.harass ?? true) takeHarassCard(game, owner, HARASS_WITHDRAW_CARD);
|
||
staging.push('Return all original hands, including Ix setup stock, to undealt stock; conserve original R and optional H into Ix’s real slots. This is not a purchase.');
|
||
const beforeBattle = structuredClone(game);
|
||
const choice = viewGame(game, game.active!).battleChoices.find(c => c.territory === 'arrakeen'); assert.ok(choice);
|
||
const battleStep: NativeWorldBattleStep = { actor: choice.chooser, action: { type: 'chooseBattle', territory: 'arrakeen',
|
||
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } };
|
||
act(battleStep);
|
||
game = advanceNativeWorldBattle(game, g => !!g.battle && harassHomeworldClean(g) &&
|
||
!g.battle.preparation && g.battle.preLeader?.closed !== false, nativeSteps,
|
||
{ skilled: options.skilled ? [owner] : [] });
|
||
const living = (actor: string) => nativeWorldBattlePlayer(game, actor).leaders.filter(l => !l.dead && !l.usedAt &&
|
||
(!game.leaderSkills?.assignments.some(a => a.owner === actor && a.leader === l.id) || actor === owner && options.skilled))
|
||
.sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id));
|
||
const trainer = options.skilled ? game.leaderSkills?.assignments.find(a => a.owner === owner) : null;
|
||
assert.ok(!options.skilled || trainer, 'Select Skills before native setup to use its real trainer');
|
||
const ownLeader = trainer ? living(owner).find(l => l.id === trainer.leader)! : living(owner).at(-1)!;
|
||
const otherLeader = living(opponent)[0];
|
||
assert.ok(ownLeader && otherLeader);
|
||
conserveDancer(game, ownLeader.id, staging);
|
||
const support = advanced ? options.support ?? 3 : 0;
|
||
const sources = { 'homeworld:ixians': { normal: 3, elite: 0 } };
|
||
// Exactly three Cyborgs are committed in every programme. At half support
|
||
// they dial three; each actually supported Cyborg adds one, not another token.
|
||
const planActions: NativeWorldPhaseBattleFixture['planActions'] = [
|
||
{ actor: owner, action: { type: 'battlePlan', dial: advanced ? 3 + support : 6, support,
|
||
leader: ownLeader.id, weapon: REINFORCEMENTS_CARD, defense: (options.harass ?? true) ? HARASS_WITHDRAW_CARD : null,
|
||
reinforcementsSources: sources } },
|
||
{ actor: opponent, action: { type: 'battlePlan', dial: 0, support: 0, leader: otherLeader.id } },
|
||
];
|
||
const returns = { [location]: { normal: 3, elite: 0 } };
|
||
assert.deepEqual(game.battle!.plans, {});
|
||
assertNativeWorldBattleInventory(game, afterSetup);
|
||
return { initial: setup.initial, afterSetup, beforeFirstMentat, firstMentatStep, afterFirstMentat, shipments,
|
||
beforeBattle, battleStep, game, owner, opponent, observer, location, planActions, sources, returns, nativeSteps, staging };
|
||
}
|
||
|
||
export function revealNativeWorldPhaseBattle(fixture: NativeWorldPhaseBattleFixture): Game {
|
||
let game = structuredClone(fixture.game);
|
||
for (const step of fixture.planActions) {
|
||
game = applyAction(game, step.actor, step.action);
|
||
game = advanceNativeWorldBattle(game, g => !!g.battle && harassHomeworldClean(g) &&
|
||
!g.battle.preparation && g.battle.preLeader?.closed !== false, [], { returns: fixture.returns });
|
||
}
|
||
assert.ok(game.battle?.revealed); return game;
|
||
}
|
||
export function settleNativeWorldPhaseBattle(game: Game, fixture: NativeWorldPhaseBattleFixture): Game {
|
||
return advanceNativeWorldBattle(game, g => g.decision?.kind === 'faceDance', [],
|
||
{ returns: fixture.returns, casualties: { normal: 0, elite: 3 } });
|
||
}
|
||
export function finishNativeWorldPhaseBattle(game: Game, fixture: NativeWorldPhaseBattleFixture,
|
||
steps: NativeWorldBattleStep[] = []): Game {
|
||
if (game.decision?.kind === 'faceDance' && !game.response && !game.phaseOpening) {
|
||
const step: NativeWorldBattleStep = { actor: game.decision.player, action: { type: 'decision', reveal: false } };
|
||
steps.push(step); game = applyAction(game, step.actor, step.action);
|
||
}
|
||
return advanceNativeWorldBattle(game, nativeWorldBattleFinished, steps,
|
||
{ returns: fixture.returns, casualties: { normal: 0, elite: 3 } });
|
||
}
|
||
|
||
/** Original native programmes with unused modules, not invented module benefits.
|
||
* The visitor variant retains exact mixed fee custody and optional native
|
||
* Cyborg/Face Dance aftercare; own Ix retains its original H prohibition.
|
||
*/
|
||
export function createNativeWorldPhaseForeignBattleFixture(options: {
|
||
advanced?: boolean; typedAftercare?: boolean; skills?: boolean;
|
||
training?: IndependentCardModulesOptions['training']; skilled?: boolean;
|
||
} = {}): NativeWorldBattleFixture {
|
||
const advanced = options.advanced ?? true;
|
||
return createNativeWorldBattleFixture({ advanced, tech: true, strongholds: advanced,
|
||
skills: options.skills, training: options.training, skilled: options.skilled,
|
||
...(options.typedAftercare ? { exportNormal: 9, exportElite: 1, ownerDial: advanced ? 1 : 2,
|
||
harass: false, outcome: 'win' as const, faceDancer: true } : {}) });
|
||
}
|
||
export function createNativeWorldPhaseOwnBattleFixture(advanced = true,
|
||
options: Pick<NativeWorldPhaseBattleOptions, 'skills' | 'training' | 'skilled'> = {}): NativeWorldBattleFixture {
|
||
return createNativeWorldBattleFixture({ advanced, tech: true, strongholds: advanced, route: 'ix-native', ...options });
|
||
}
|