212 lines
9.9 KiB
TypeScript
212 lines
9.9 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { applyAction, viewGame, type Game, type Player } from '../game/engine';
|
|
import {
|
|
ecazOccupyFixture, chooseEcazOccupyLead, cancelEcazOccupy, openEcazOccupyPlans,
|
|
allowEcazOccupyResponses, stageEcazOccupyCard, type EcazOccupyFixture,
|
|
} from './fixture-ecaz-occupy';
|
|
|
|
export type NativeOccupyLead = 'ecaz' | 'ally';
|
|
export const nativeOccupyPlayer = (game: Game, id: string): Player => {
|
|
const player = game.players.find(p => p.id === id);
|
|
assert.ok(player, 'The original native roster must contain this player.');
|
|
return player;
|
|
};
|
|
|
|
function availableLeader(game: Game, id: string, strongest: boolean): string {
|
|
const available = nativeOccupyPlayer(game, id).leaders.filter(l => !l.dead && !l.usedAt);
|
|
available.sort((a, b) => strongest ? b.strength - a.strength : a.strength - b.strength);
|
|
assert.ok(available[0], 'The native roster must supply an available physical leader.');
|
|
return available[0].id;
|
|
}
|
|
|
|
function prepare(fixture: EcazOccupyFixture, lead: NativeOccupyLead,
|
|
canceled: boolean, funding = 0): Game {
|
|
let game = canceled ? cancelEcazOccupy(fixture, lead) : chooseEcazOccupyLead(fixture, lead);
|
|
game = allowEcazOccupyResponses(game);
|
|
if (game.decision?.kind === 'choamBattleFunding')
|
|
game = applyAction(game, game.decision.player, { type: 'decision', amount: funding });
|
|
else assert.equal(funding, 0, 'Donor spice requires the original native CHOAM funding offer.');
|
|
return openEcazOccupyPlans(game);
|
|
}
|
|
|
|
function reveal(state: Game, actor: string, caller?: string): Game {
|
|
let game = state;
|
|
for (let attempt = 0; attempt < 10 && game.battle; attempt++) {
|
|
const battle = viewGame(game, actor).battle;
|
|
assert.ok(battle);
|
|
const voter = battle.traitorVoters.find(id => !battle.traitorSubmitted.includes(id));
|
|
if (!voter) break;
|
|
game = applyAction(game, voter, { type: 'traitorCall', call: voter === caller });
|
|
}
|
|
return game;
|
|
}
|
|
|
|
/** Only consume real aftermath windows. Never install a decision, plan or phase. */
|
|
export function finishNativeOccupyAftermath(state: Game, discardCard?: string): Game {
|
|
let game = state;
|
|
for (let attempt = 0; attempt < 100; attempt++) {
|
|
if (game.pendingTreacheryDiscard) {
|
|
game = applyAction(game, game.players[0].id, { type: 'advanceBots' });
|
|
} else if (game.response) {
|
|
game = allowEcazOccupyResponses(game);
|
|
} else if (game.decision?.kind === 'battleCards') {
|
|
const discard = discardCard && game.decision.cards.includes(discardCard) ? [discardCard] : [];
|
|
game = applyAction(game, game.decision.player, { type: 'decision', discard });
|
|
} else if (game.decision?.kind === 'choamMarket') {
|
|
game = applyAction(game, game.decision.player, { type: 'decision', done: true });
|
|
} else {
|
|
assert.equal(game.battle, null, 'Native plans and traitor voting must have resolved.');
|
|
assert.equal(game.decision, null, 'Native typed losses/substitution must precede continuation.');
|
|
assert.ok(game.phase >= 7, 'The actual coalition battle must reach native Collection.');
|
|
return game;
|
|
}
|
|
}
|
|
throw new Error('The native Occupy battle aftermath did not finish.');
|
|
}
|
|
|
|
export type NativeIxOccupyOptions = {
|
|
initial?: Game;
|
|
lead?: NativeOccupyLead;
|
|
canceled?: boolean;
|
|
commitment?: 'funded' | 'half' | 'zero';
|
|
};
|
|
export type NativeIxOccupyCase = {
|
|
fixture: EcazOccupyFixture;
|
|
actor: string;
|
|
shield: string;
|
|
variableDial: number;
|
|
support: number;
|
|
before: Game;
|
|
afterReveal: Game;
|
|
afterLosses: Game;
|
|
afterSubstitution: Game;
|
|
game: Game;
|
|
};
|
|
|
|
/** Original Ix setup, real Nexus and mixed shipment (six Suboids, two Cyborgs).
|
|
* Only conserved original hand-card staging is added to the common fixture. */
|
|
export function nativeIxOccupyCase(options: NativeIxOccupyOptions = {}): NativeIxOccupyCase {
|
|
const lead = options.lead ?? 'ecaz';
|
|
const canceled = options.canceled ?? false;
|
|
const commitment = options.commitment ?? 'funded';
|
|
const fixture = ecazOccupyFixture({ initial: options.initial, allyFaction: 'ixians',
|
|
expansions: options.initial?.expansions ?? ['ecaz', 'ix'], ecazForces: 5, allyForces: 8, allyElite: 2 });
|
|
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
|
|
const shield = stageEcazOccupyCard(fixture.game, actor, card => card.kind === 'shield');
|
|
let game = prepare(fixture, lead, canceled);
|
|
const before = structuredClone(game);
|
|
const profile = viewGame(game, actor).battle?.ecazOccupy?.profile;
|
|
assert.ok(profile);
|
|
const ecazOwn = canceled && lead === 'ecaz';
|
|
const variableDial = ecazOwn ? 2 : commitment === 'funded' ? 4 : commitment === 'half' ? 3 : 0;
|
|
const support = ecazOwn ? 2 : commitment === 'funded' ? 1 : 0;
|
|
game = applyAction(game, actor, { type: 'battlePlan', dial: profile.fixedEcazDial + variableDial,
|
|
support, leader: availableLeader(game, actor, true), defense: shield });
|
|
game = applyAction(game, fixture.opponent, { type: 'battlePlan', dial: 0, support: 0,
|
|
leader: availableLeader(game, fixture.opponent, false) });
|
|
game = reveal(game, actor);
|
|
const afterReveal = structuredClone(game);
|
|
const normal = ecazOwn ? 2 : commitment === 'funded' ? 2 : commitment === 'half' ? 4 : 0;
|
|
const elite = ecazOwn || commitment === 'zero' ? 0 : commitment === 'funded' ? 2 : 1;
|
|
if (game.decision?.kind === 'battleLosses') {
|
|
const choice = game.decision.options.findIndex(loss => loss.normal === normal && loss.elite === elite);
|
|
assert.ok(choice >= 0, 'The actual Ix dial must offer the requested native typed allocation.');
|
|
game = applyAction(game, game.decision.player, { type: 'decision', choice });
|
|
}
|
|
const afterLosses = structuredClone(game);
|
|
if (elite > 0) {
|
|
assert.ok(game.decision?.kind === 'ixSubstitution');
|
|
assert.equal(game.decision.player, fixture.ally, 'Only the actual Ixian chooses surviving Suboids.');
|
|
game = applyAction(game, fixture.ally, { type: 'decision',
|
|
sources: { [fixture.location]: elite }, recover: { [fixture.location]: elite } });
|
|
game = allowEcazOccupyResponses(game);
|
|
} else assert.notEqual(game.decision?.kind, 'ixSubstitution');
|
|
const afterSubstitution = structuredClone(game);
|
|
game = finishNativeOccupyAftermath(game, shield);
|
|
return { fixture, actor, shield, variableDial, support, before, afterReveal,
|
|
afterLosses, afterSubstitution, game };
|
|
}
|
|
|
|
export type NativeChoamOccupyOptions = {
|
|
initial?: Game;
|
|
lead?: NativeOccupyLead;
|
|
relationship?: 'coside' | 'opposing';
|
|
support?: number;
|
|
opponentSupport?: number;
|
|
donorPayment?: number;
|
|
traitor?: boolean;
|
|
};
|
|
export type NativeChoamOccupyCase = {
|
|
fixture: EcazOccupyFixture;
|
|
actor: string;
|
|
choam: string;
|
|
support: number;
|
|
opponentSupport: number;
|
|
donorPayment: number;
|
|
beforeFunding: Game;
|
|
before: Game;
|
|
revealed: Game;
|
|
afterReveal: Game;
|
|
game: Game;
|
|
};
|
|
|
|
/** Labelled physical Traitor Card exchange after original setup. Preserve the
|
|
* original deal and reserve; never synthesize a traitor identity or dead leader. */
|
|
function holdPhysicalTraitor(game: Game, owner: string, identity: string): void {
|
|
const player = nativeOccupyPlayer(game, owner);
|
|
if (player.traitors.includes(identity)) return;
|
|
assert.ok(player.traitors[0], 'An original held Traitor Card must fund this physical exchange.');
|
|
const reserve = game.traitorReserve;
|
|
const index = reserve?.indexOf(identity) ?? -1;
|
|
if (reserve && index >= 0) {
|
|
reserve[index] = player.traitors[0];
|
|
player.traitors[0] = identity;
|
|
} else {
|
|
const donor = game.players.find(p => p.traitors.includes(identity));
|
|
assert.ok(donor, 'The original source must contain the requested physical Traitor Card.');
|
|
const donorIndex = donor.traitors.indexOf(identity);
|
|
donor.traitors[donorIndex] = player.traitors[0];
|
|
player.traitors[0] = identity;
|
|
}
|
|
}
|
|
|
|
/** Native CHOAM original family setup and actual opposing/coside armies.
|
|
* Any donor payment is offered and escrowed by the existing CHOAM decision. */
|
|
export function nativeChoamOccupyCase(options: NativeChoamOccupyOptions = {}): NativeChoamOccupyCase {
|
|
const lead = options.lead ?? 'ecaz';
|
|
const relationship = options.relationship ?? 'coside';
|
|
const support = options.support ?? 3;
|
|
const opponentSupport = options.opponentSupport ?? 3;
|
|
const donorPayment = options.donorPayment ?? 0;
|
|
const fixture = ecazOccupyFixture({ initial: options.initial,
|
|
allyFaction: relationship === 'coside' ? 'choam' : 'guild',
|
|
opponentFaction: relationship === 'coside' ? 'guild' : 'choam',
|
|
expansions: options.initial?.expansions ?? ['ecaz', 'choam'], ecazForces: 5, allyForces: 8 });
|
|
const actor = lead === 'ecaz' ? fixture.ecaz : fixture.ally;
|
|
const choam = relationship === 'coside' ? fixture.ally : fixture.opponent;
|
|
const beforeFunding = structuredClone(fixture.game);
|
|
let game = prepare(fixture, lead, false, donorPayment);
|
|
let opponentLeader = availableLeader(game, fixture.opponent, false);
|
|
if (options.traitor) {
|
|
const physical = [...(game.traitorReserve ?? []), ...game.players.flatMap(p => p.traitors)];
|
|
const leader = nativeOccupyPlayer(game, fixture.opponent).leaders.find(l =>
|
|
!l.dead && !l.usedAt && physical.includes(l.id));
|
|
assert.ok(leader, 'The actual opposing roster must supply a physical non-loyal Traitor Card.');
|
|
opponentLeader = leader.id;
|
|
holdPhysicalTraitor(game, actor, opponentLeader);
|
|
}
|
|
const before = structuredClone(game);
|
|
const profile = viewGame(game, actor).battle?.ecazOccupy?.profile;
|
|
assert.ok(profile);
|
|
game = applyAction(game, actor, { type: 'battlePlan', dial: profile.fixedEcazDial + support,
|
|
support, allyPayment: donorPayment, leader: availableLeader(game, actor, true) });
|
|
game = applyAction(game, fixture.opponent, { type: 'battlePlan', dial: opponentSupport,
|
|
support: opponentSupport, leader: opponentLeader });
|
|
const revealed = structuredClone(game);
|
|
game = reveal(game, actor, options.traitor ? actor : undefined);
|
|
const afterReveal = structuredClone(game);
|
|
game = finishNativeOccupyAftermath(game);
|
|
return { fixture, actor, choam, support, opponentSupport, donorPayment,
|
|
beforeFunding, before, revealed, afterReveal, game };
|
|
}
|