Files
Dune/tests/fixture-reinforcements-homeworld.ts
T

231 lines
13 KiB
TypeScript
Raw 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 { viewGame, type Game } from '../game/engine';
import { combatArmy } from '../game/combat-location';
import type { NativeReserveSelections } from '../game/homeworld-native-reserves';
import type { HomeworldForces } from '../game/homeworld-custody';
import { nativeShipmentSources } from '../game/homeworld-options';
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
import { takeHarassCard } from './fixture-harass-withdraw';
import type { IndependentCardModulesOptions } from './fixture-independent-card-modules';
import {
actHarassHomeworld, advanceHarassHomeworld, assertHarassHomeworldCustody,
createHarassHomeworldSetup, harassHomeworldClean, harassHomeworldPlayer,
harassHomeworldPool, nextHarassHomeworldStep, sealHarassHomeworldPlan,
type HarassHomeworldSetup, type HarassHomeworldStep,
} from './fixture-harass-homeworld';
export { REINFORCEMENTS_CARD };
export const REINFORCEMENTS_KAITAIN = 'homeworld:emperor';
export const REINFORCEMENTS_SALUSA = 'homeworld:emperor:salusa';
export type ReinforcementsHomeworldStep = HarassHomeworldStep;
export type ReinforcementsHomeworldOptions = {
advanced?: boolean;
initial?: Game;
skills?: boolean;
training?: IndependentCardModulesOptions['training'];
tech?: boolean;
strongholds?: boolean;
/** Use the genuinely assigned trainer after the original visibility choice. */
skilled?: boolean;
seatIds?: [string, string, string];
role?: 'visitor' | 'native';
secondary?: boolean;
slot?: 'weapon' | 'defense';
outcome?: 'win' | 'loss' | 'traitor' | 'explosion';
/** Real first-turn Kaitain → Salusa movement, never force staging. */
nativeTransfer?: number;
sources?: NativeReserveSelections;
ownerDial?: number;
ownerSupport?: number;
};
export type ReinforcementsHomeworldFixture = HarassHomeworldSetup & {
options: ReinforcementsHomeworldOptions;
role: 'visitor' | 'native';
location: string;
native: string;
visitor: string;
beforeNativeMove: Game;
nativeMove: ReinforcementsHomeworldStep | null;
afterNativeMove: Game;
beforeShipment: Game;
shipmentStep: ReinforcementsHomeworldStep;
afterShipment: Game;
beforeBattle: Game;
battleStep: ReinforcementsHomeworldStep;
game: Game;
planActions: [ReinforcementsHomeworldStep, ReinforcementsHomeworldStep];
sources: NativeReserveSelections;
staging: string[];
};
export const reinforcementsHomeworldPlayer = harassHomeworldPlayer;
export const reinforcementsHomeworldPool = harassHomeworldPool;
export const assertReinforcementsHomeworldCustody = assertHarassHomeworldCustody;
export const advanceReinforcementsHomeworld = advanceHarassHomeworld;
export const reinforcementsHomeworldClean = harassHomeworldClean;
export const sealReinforcementsHomeworldPlan = sealHarassHomeworldPlan;
/** Explicit normal-only fee alternatives; source keys are full native World IDs. */
export function reinforcementsHomeworldSources(salusaNormal = 0): NativeReserveSelections {
assert.ok(Number.isSafeInteger(salusaNormal) && salusaNormal >= 0 && salusaNormal <= 3);
return {
...(salusaNormal < 3 ? { [REINFORCEMENTS_KAITAIN]: { normal: 3 - salusaNormal, elite: 0 } } : {}),
...(salusaNormal > 0 ? { [REINFORCEMENTS_SALUSA]: { normal: salusaNormal, elite: 0 } } : {}),
};
}
function conserveTraitor(game: Game, actor: string, identity: string, staging: string[]): void {
const player = harassHomeworldPlayer(game, actor);
if (player.traitors.includes(identity)) return;
const returned = player.traitors[0]; assert.ok(returned);
const reserveIndex = game.traitorReserve!.indexOf(identity);
const donor = game.players.find(p => p.id !== actor && p.traitors.includes(identity));
assert.ok(reserveIndex >= 0 || donor, 'The original Traitor identity must have actual custody');
if (reserveIndex >= 0) game.traitorReserve!.splice(reserveIndex, 1, returned);
else donor!.traitors[donor!.traitors.indexOf(identity)] = returned;
player.traitors[0] = identity;
staging.push(`Conserved original Traitor ${identity}: ${donor?.id ?? 'undealt reserve'} exchanged for ${returned}.`);
}
/** Original independent 36-card classic setup, native movement, paid typed
* invasion and actual chooseBattle/preparation. Only conserved original hands,
* Traitor identities and unopened Spice order are positioned and labeled.
* No forces, wallet, phase, history, battle receipt or World face is assigned.
* Fresh authenticated lobby/seat IDs are accepted for mobile and policy runs. */
export function createReinforcementsHomeworldFixture(
options: ReinforcementsHomeworldOptions = {},
): ReinforcementsHomeworldFixture {
const setup = createHarassHomeworldSetup(options);
const role = options.role ?? 'visitor', outcome = options.outcome ?? 'loss';
assert.ok(!options.secondary || role === 'native' && setup.afterSetup.advanced);
assert.ok(outcome !== 'explosion' || options.slot !== 'weapon', 'Lasgun occupies the weapon slot');
let game = structuredClone(setup.afterSetup);
const staging: string[] = [];
for (let i = 0; i < (game.advanced ? 4 : 2); i++) {
const at = game.spiceDeck.findIndex((c, index) => index >= i && 'territory' in c && !c.discovery);
assert.ok(at >= i); game.spiceDeck.splice(i, 0, game.spiceDeck.splice(at, 1)[0]);
}
staging.push('Position unopened original non-worm Spice Cards for the first native Spice Blow; no phase or storm assignment.');
const act = (step: ReinforcementsHomeworldStep) => {
game = actHarassHomeworld(game, step, setup.nativeSteps);
};
const reach = (until: (g: Game) => boolean) => {
game = advanceHarassHomeworld(game, until, setup.nativeSteps);
};
reach(g => g.phase === 5 && g.active === setup.owner && harassHomeworldClean(g));
let beforeNativeMove = structuredClone(game), afterNativeMove = structuredClone(game);
let nativeMove: ReinforcementsHomeworldStep | null = null;
const moveNative = () => {
beforeNativeMove = structuredClone(game);
if (game.advanced) {
const normal = options.nativeTransfer ?? (role === 'visitor' || options.secondary ? 2 : 10);
const movement = viewGame(game, setup.owner).homeworldMove; assert.ok(movement && !movement.blocked);
nativeMove = { actor: setup.owner, action: { type: 'emperorHomeworldMove', event: movement.event,
origin: REINFORCEMENTS_KAITAIN, normal, elite: 0 } };
act(nativeMove);
} else assert.equal(options.nativeTransfer ?? 0, 0, 'Basic has no inter-World movement');
afterNativeMove = structuredClone(game);
};
// Moving begins movement and consumes the unused shipment opportunity.
// A visiting Emperor must therefore invade first, then move his reserves.
if (role === 'native') moveNative();
const native = role === 'visitor' ? setup.opponent : setup.owner;
const visitor = role === 'visitor' ? setup.owner : setup.opponent;
const location = role === 'visitor' ? 'homeworld:guild'
: options.secondary ? REINFORCEMENTS_SALUSA : REINFORCEMENTS_KAITAIN;
reach(g => g.phase === 5 && g.active === visitor && harassHomeworldClean(g));
const beforeShipment = structuredClone(game);
const amount = role === 'visitor' ? 7 : 5, elite = role === 'visitor' ? 4 : 0;
const shipmentSources = nativeShipmentSources(viewGame(game, visitor), amount, elite); assert.ok(shipmentSources);
const choice = homeworldShipmentChoice(viewGame(game, visitor), location,
Object.fromEntries(Object.entries(shipmentSources).filter(([, f]) => f.normal + f.elite > 0)));
assert.ok(choice.action, choice.blocked ?? 'The original typed invasion is available');
const shipmentStep = { actor: visitor, action: choice.action }; act(shipmentStep);
reach(g => !g.pendingHomeworldShipment && !g.pendingShipment && harassHomeworldClean(g));
const afterShipment = structuredClone(game);
assert.deepEqual(combatArmy(game, visitor, location), role === 'visitor'
? { normal: 3, elite: 4 } : { normal: 5, elite: 0 });
if (role === 'visitor') moveNative();
reach(g => g.phase === 6 && harassHomeworldClean(g) && !!g.active);
for (const p of game.players) game.deck.push(...p.hand.splice(0));
takeHarassCard(game, setup.owner, REINFORCEMENTS_CARD);
let laser: string | undefined, shield: string | undefined;
if (outcome === 'explosion') {
laser = game.deck.find(c => c.kind === 'lasgun')!.id;
shield = game.deck.find(c => c.kind === 'shield')!.id;
takeHarassCard(game, setup.owner, laser); takeHarassCard(game, setup.opponent, shield);
}
staging.push('Return original hands to undealt stock, then conserve the physical Reinforcements and (explosion only) Lasgun/Shield into their owners’ hands; no card is manufactured.');
const living = (actor: string) => harassHomeworldPlayer(game, actor).leaders.filter(l => !l.dead && !l.usedAt &&
(!game.leaderSkills?.assignments.some(a => a.owner === actor && a.leader === l.id) || actor === setup.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 === setup.owner) : null;
assert.ok(!options.skilled || trainer, 'Select Skills before native setup to use its real trainer');
const ownLeader = trainer ? living(setup.owner).find(l => l.id === trainer.leader)!
: outcome === 'win' ? living(setup.owner).at(-1)! : living(setup.owner)[0];
const otherLeader = outcome === 'win' ? living(setup.opponent)[0] : living(setup.opponent).at(-1)!;
if (outcome === 'traitor') conserveTraitor(game, native,
native === setup.owner ? otherLeader.id : ownLeader.id, staging);
const beforeBattle = structuredClone(game);
const battleChoice = viewGame(game, game.active!).battleChoices.find(c => c.territory === location);
assert.ok(battleChoice);
const battleStep: ReinforcementsHomeworldStep = { actor: battleChoice.chooser,
action: { type: 'chooseBattle', territory: location,
target: battleChoice.attacker === battleChoice.chooser ? battleChoice.defender : battleChoice.attacker } };
act(battleStep);
for (let n = 0; n < 300; n++) {
if (game.battle && harassHomeworldClean(game) && !game.battle.preparation && game.battle.preLeader?.closed !== false) break;
const step = nextHarassHomeworldStep(game); assert.ok(step, 'Complete the original battle preparation');
if (step.action.type === 'leaderSkillVisibility') step.action.hide = step.actor === setup.owner && !!options.skilled;
act(step);
}
assert.ok(game.battle && harassHomeworldClean(game) && !game.battle.preparation && game.battle.preLeader?.closed !== false);
assert.equal(Object.keys(game.battle!.plans).length, 0);
const sources = structuredClone(options.sources ?? reinforcementsHomeworldSources());
const dial = options.ownerDial ?? (role === 'visitor' || outcome === 'win' ? 2 : 0);
const support = options.ownerSupport ?? (game.advanced ? dial : 0);
const slot = options.slot ?? 'defense';
const opponentSupport = game.advanced && outcome !== 'win' && outcome !== 'explosion'
? Math.min(role === 'visitor' ? 3 : 5, harassHomeworldPlayer(game, setup.opponent).spice) : 0;
const opponentDial = outcome === 'win' || outcome === 'explosion' ? 0 : role === 'visitor' ? 3
: game.advanced ? (5 + opponentSupport) / 2 : 5;
const planActions: ReinforcementsHomeworldFixture['planActions'] = [
{ actor: setup.owner, action: { type: 'battlePlan', dial, support, leader: ownLeader.id,
weapon: slot === 'weapon' ? REINFORCEMENTS_CARD : laser ?? null,
defense: slot === 'defense' ? REINFORCEMENTS_CARD : null,
reinforcementsSources: sources } },
{ actor: setup.opponent, action: { type: 'battlePlan',
dial: opponentDial, support: opponentSupport,
leader: otherLeader.id, defense: shield ?? null } },
];
assertHarassHomeworldCustody(game);
return { ...setup, options, role, location, native, visitor, beforeNativeMove, nativeMove, afterNativeMove,
beforeShipment, shipmentStep, afterShipment, beforeBattle, battleStep, game, planActions, sources, staging };
}
export function revealReinforcementsHomeworldBattle(fixture: ReinforcementsHomeworldFixture): Game {
let game = structuredClone(fixture.game);
for (const step of fixture.planActions) game = sealHarassHomeworldPlan(game, step);
assert.ok(game.battle?.revealed); return game;
}
/** Human-owned native Traitor/explosion choices remain explicit. Real mandatory
* discard, typed winner casualties and legal aftercare use existing controls. */
export function finishReinforcementsHomeworldBattle(game: Game, options: {
callers?: string[];
explosionLosses?: HomeworldForces;
actions?: ReinforcementsHomeworldStep[];
} = {}): Game {
for (let n = 0; n < 240; n++) {
if (!game.battle && !game.homeworldBattleLoss && harassHomeworldClean(game)) return game;
let step: ReinforcementsHomeworldStep | null;
if (!game.response && !game.phaseOpening && game.decision?.kind === 'homeworldExplosion' && options.explosionLosses) {
const d = game.decision;
const choice = d.options.findIndex(f => f.normal === options.explosionLosses!.normal && f.elite === options.explosionLosses!.elite);
assert.ok(choice >= 0, 'Choose a real native typed explosion option');
step = { actor: d.player, action: { type: 'decision', event: d.event, choice } };
} else step = nextHarassHomeworldStep(game);
assert.ok(step, 'Both genuine plans must already be sealed');
if (step.action.type === 'traitorCall') step.action.call = options.callers?.includes(step.actor) ?? false;
game = actHarassHomeworld(game, step, options.actions);
}
throw Error('Original Reinforcements Homeworld battle did not complete');
}