231 lines
13 KiB
TypeScript
231 lines
13 KiB
TypeScript
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');
|
||
}
|