Files
Dune/tests/fixture-ecaz-occupy-mixed-no-field.ts
T

159 lines
7.9 KiB
TypeScript

import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import type { NoFieldValue, RicheseNoField } from '../game/richese-no-field';
import { defaultHarassWithdrawAllocation } from '../game/harass-withdraw';
import { stageEcazOccupyCard } from './fixture-ecaz-occupy';
import { holdOccupyCardsTraitor } from './fixture-ecaz-occupy-cards';
import {
createNoFieldReinforcementsSetup, NO_FIELD_HARASS, NO_FIELD_REINFORCEMENTS,
} from './fixture-no-field-reinforcements';
import {
ecazOccupyRicheseFixture, openOccupyRichese, finishOccupyRichese, nextOccupyRichese,
occupyRicheseSeat, type EcazOccupyRicheseFixture,
} from './fixture-ecaz-occupy-richese';
export type EcazOccupyMixedNoFieldOptions = {
/** Original fresh Advanced setup; the independent cards must already be selected. */
initial?: Game;
lead?: 'ecaz' | 'ally';
marker?: NoFieldValue;
ordinary?: number;
richeseReserves?: number;
ecazReserves?: number;
variableDial?: number;
support?: number;
opposingTraitor?: boolean;
};
export type EcazOccupyMixedNoFieldPlan = 'plain' | 'harass' | 'reinforcements' | 'both';
export type EcazOccupyMixedNoFieldFixture = {
source: EcazOccupyRicheseFixture;
initial: Game;
afterSetup: Game;
/** Admitted coalition, before choosing the lead. */
beforeLead: Game;
/** Native preparation complete, before either sealed plan; game is the same control. */
preplan: Game;
game: Game;
owner: string;
ecaz: string;
ally: string;
richese: string;
opponent: string;
territory: string;
location: string;
ordinary: number;
marker: NoFieldValue;
materializes: number;
noField: RicheseNoField;
fixedEcazDial: number;
variableDial: number;
support: number;
opposingTraitor: boolean;
plans: Record<EcazOccupyMixedNoFieldPlan | 'opponent', Action>;
staging: string[];
};
/** Native Advanced variable ALLY with ordinary counters plus its own concealed
* marker. The shared source performs setup, Nexus, conserved board staging,
* actual No-Field shipment and battle admission. No phase/window is replaced. */
export function ecazOccupyMixedNoFieldFixture(
options: EcazOccupyMixedNoFieldOptions = {},
): EcazOccupyMixedNoFieldFixture {
const marker = options.marker ?? 3;
const ordinary = options.ordinary ?? 2;
assert.ok(Number.isSafeInteger(ordinary) && ordinary > 0);
const source = ecazOccupyRicheseFixture({
initial: options.initial ?? createNoFieldReinforcementsSetup(),
allyFaction: 'richese', opponentFaction: 'guild', marker,
mixedRicheseForces: ordinary, richeseReserves: options.richeseReserves ?? 6,
});
assert.equal(source.initial.ecazTreachery, true,
'Select the original independent H/R inventory before native setup, not afterward.');
const owner = options.lead === 'ecaz' ? source.ecaz : source.richese;
// Preserve every original physical face, including auction acquisitions,
// before staging two utility faces in a hand below the native four-card cap.
for (const player of source.game.players) {
source.game.deck.push(...player.hand);
player.hand = [];
}
stageEcazOccupyCard(source.game, owner, card => card.id === NO_FIELD_HARASS);
stageEcazOccupyCard(source.game, owner, card => card.id === NO_FIELD_REINFORCEMENTS);
source.staging.push('Return all unselected original hands to the actual remaining deck; stage only the original Harass & Withdraw and Reinforcements identities in the selected lead hand.');
if (options.ecazReserves !== undefined) {
const player = occupyRicheseSeat(source.game, source.ecaz);
assert.ok(Number.isSafeInteger(options.ecazReserves) && options.ecazReserves >= 0 && options.ecazReserves <= player.reserves);
const relocated = player.reserves - options.ecazReserves;
if (relocated) player.forces['polar_sink:0'] = (player.forces['polar_sink:0'] ?? 0) + relocated;
player.reserves -= relocated;
source.staging.push(`Conserve Ecaz counters: move ${relocated} unused reserves to Polar Sink, leaving ${options.ecazReserves} own reserves.`);
}
const beforeLead = structuredClone(source.game);
const game = openOccupyRichese(source, options.lead ?? 'ally');
const profile = viewGame(game, owner).battle?.ecazOccupy?.profile;
assert.ok(profile);
const noField = structuredClone(occupyRicheseSeat(game, source.richese).noField!);
assert.ok(noField.deployed);
const materializes = Math.min(marker, occupyRicheseSeat(game, source.richese).reserves);
const physicalTraitors = new Set([...(game.traitorReserve ?? []), ...game.players.flatMap(player => player.traitors)]);
const leader = occupyRicheseSeat(game, owner).leaders
.filter(face => !face.dead && !face.usedAt && (!options.opposingTraitor || physicalTraitors.has(face.id)))
.sort((a, b) => b.strength - a.strength || a.id.localeCompare(b.id))[0];
const enemyLeader = occupyRicheseSeat(game, source.opponent).leaders
.filter(face => !face.dead && !face.usedAt)
.sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id))[0];
assert.ok(leader && enemyLeader);
if (options.opposingTraitor) {
holdOccupyCardsTraitor(game, source.opponent, leader.id);
source.staging.push(`Exchange an actual original Traitor Card for ${leader.id} in the opposing hand.`);
}
const variableDial = options.variableDial ?? Math.min(3, ordinary + materializes);
const support = options.support ?? variableDial;
const plain: Action = { type: 'battlePlan', dial: profile.fixedEcazDial + variableDial,
support, leader: leader.id };
return {
source, initial: source.initial, afterSetup: source.afterSetup, beforeLead,
preplan: game, game, owner, ecaz: source.ecaz, ally: source.ally, richese: source.richese,
opponent: source.opponent, territory: source.territory, location: source.location,
ordinary, marker, materializes, noField, fixedEcazDial: profile.fixedEcazDial,
variableDial, support, opposingTraitor: options.opposingTraitor ?? false,
plans: { plain, harass: { ...plain, defense: NO_FIELD_HARASS },
reinforcements: { ...plain, weapon: NO_FIELD_REINFORCEMENTS },
both: { ...plain, weapon: NO_FIELD_REINFORCEMENTS, defense: NO_FIELD_HARASS },
opponent: { type: 'battlePlan', dial: 0, support: 0, leader: enemyLeader.id } },
staging: source.staging,
};
}
/** The second actual seal, not this helper, produces the native reveal receipt. */
export function sealEcazOccupyMixedNoField(fixture: EcazOccupyMixedNoFieldFixture,
plan: EcazOccupyMixedNoFieldPlan = 'plain', state = fixture.game): Game {
const first = applyAction(state, fixture.owner, fixture.plans[plan]);
return applyAction(first, fixture.opponent, fixture.plans.opponent);
}
/** Consume real allocation/response/Traitor decisions, then native aftermath,
* including any original CHOAM market. Never clear a live decision or window. */
export function finishEcazOccupyMixedNoField(fixture: EcazOccupyMixedNoFieldFixture,
state: Game): { afterVotes: Game; game: Game } {
let game = state;
for (let attempt = 0; attempt < 80; attempt++) {
if (game.response || game.phaseOpening) {
game = nextOccupyRichese(game);
continue;
}
if (game.decision?.kind === 'harassWithdraw') {
const decision = game.decision;
const allocation = viewGame(game, decision.player).battle?.harassAllocation;
assert.ok(allocation);
game = applyAction(game, decision.player, { type: 'decision', event: decision.event,
returns: defaultHarassWithdrawAllocation(allocation.context, allocation.dial, allocation.support) });
continue;
}
const battle = viewGame(game, fixture.owner).battle;
const voter = !game.decision && battle?.traitorVoters.find(id => !battle.traitorSubmitted.includes(id));
if (!voter) return { afterVotes: game, game: finishOccupyRichese(game) };
game = applyAction(game, voter, { type: 'traitorCall', call: fixture.opposingTraitor && voter === fixture.opponent });
}
throw Error('Mixed Occupy native declaration windows did not finish.');
}