Files
Dune/game/ecaz-homeworld-victory.ts
T

166 lines
8.0 KiB
TypeScript

import type { BoardContext, BoardSeat } from './board-resolution-quote';
import type { FactionId } from './catalog';
import type { Game } from './engine';
import { HOMEWORLD_CARDS } from './homeworld-cards';
import { homeworldForceGroups } from './homeworld-custody';
import { homeworldPopulations } from './homeworld-population';
import { validateHomeworldOccupationHistory } from './homeworld-occupation-history';
import {
liveHomeworldOccupationPresence,
quoteStableHomeworldOccupation,
type StableHomeworldOccupationContext,
} from './homeworld-stable-occupation';
import { strongholdProgress, VictoryProgressError } from './victory-progress';
export type EcazHomeworldVictoryContext = Omit<BoardContext, 'players'> &
Pick<Game, 'turn' | 'homeworlds' | 'homeworldOccupationHistory' | 'homeworldOccupationPreview'> & {
homeworldEcazVictoryPreview?: true;
players: readonly (BoardSeat & {
reserves: number;
elites?: { reserves: number } | null;
})[];
};
export type EcazHomeworldVictoryProgress = {
owner: string;
members: string[];
population: number;
minimum: number;
high: boolean;
jointStrongholds: string[];
foreignHomeworlds: {
world: string;
card: string;
native: string;
faction: FactionId;
holder: string;
}[];
distinctNativeFactions: FactionId[];
qualifies: boolean;
blocked: string | null;
};
/** Printed High Ecaz victory, separate from ordinary points and occupied Duke
* benefits. Foreign holdings require the existing original source authority AND
* the entitled holder's current public presence; a concealed No-Field counts
* as one, while a departed garrison is never a victory holding. Advanced p.22
* retained occupation is otherwise respected.
* This returns public facts only, never an income receipt or a winning action. */
export function ecazHomeworldVictoryProgress(
g: EcazHomeworldVictoryContext,
): EcazHomeworldVictoryProgress | null {
// Original BoardContext callers are not opted in and need no reserve data.
if (g.homeworldEcazVictoryPreview === undefined) return null;
try {
if (g.homeworldEcazVictoryPreview !== true || g.homeworldOccupationPreview !== true)
throw new VictoryProgressError('Ecaz Homeworld victory requires the fresh occupation preview.');
if (!Number.isSafeInteger(g.turn) || g.turn < 1)
throw new VictoryProgressError('Ecaz Homeworld victory requires the current positive turn.');
const board: BoardContext = {
advanced: g.advanced,
ecazOccupyPreview: g.ecazOccupyPreview,
storm: g.storm,
order: g.order,
players: g.players,
mobileStronghold: g.mobileStronghold,
discoveries: g.discoveries,
};
// This owns alliance validation, advisor settling, public No-Field presence,
// storm/contested stronghold and HMS semantics. Tech sets are not joint sites.
const ordinary = strongholdProgress(board).progress;
const ecaz = g.players.find(player => player.faction === 'ecaz');
if (!ecaz)
throw new VictoryProgressError('Fresh Ecaz Homeworld victory requires native Ecaz.');
const state = g.homeworlds;
if (!state || typeof state !== 'object' || Array.isArray(state) ||
Object.keys(state).some(key => !['custody', 'historyVersion'].includes(key)) ||
(state.historyVersion !== undefined && state.historyVersion !== 1) || !state.custody)
throw new VictoryProgressError('Ecaz Homeworld victory requires valid native Homeworld custody.');
const context = {
advanced: g.advanced,
players: g.players.map(player => ({
id: player.id, faction: player.faction, reserves: player.reserves,
eliteReserves: player.elites?.reserves ?? 0,
})),
};
const worlds = homeworldForceGroups(context, state.custody);
const history = g.homeworldOccupationHistory;
const originalHistory = state.historyVersion === 1 && history !== undefined;
if (originalHistory) validateHomeworldOccupationHistory(history, context, g.turn);
const population = homeworldPopulations(context, state.custody)
.find(home => home.native === ecaz.id && home.card === 'ecaz')!;
const minimum = HOMEWORLD_CARDS.find(card => card.id === 'ecaz')!.high.reserves.min;
const progress = ordinary.find(row => row.player === ecaz.id)!;
const members = progress.members;
const foreignHomeworlds: EcazHomeworldVictoryProgress['foreignHomeworlds'] = [];
const possible = new Set<FactionId>();
const blockers: string[] = [];
// The stable authority's legacy Game signature is broader than its actual
// public inputs. Project explicitly rather than passing private Player data.
const publicOccupation = {
advanced: g.advanced, turn: g.turn, homeworlds: state,
homeworldOccupationPreview: g.homeworldOccupationPreview,
homeworldOccupationHistory: g.homeworldOccupationHistory,
players: g.players.map(player => {
const marker = player.noField?.deployed;
// BoardSeat exposes location but older callers need not include the
// controller. A native marker belongs to its player; when a public
// controller is supplied, retain it for the existing validation.
const controller = marker && 'controller' in marker ? marker.controller : player.id;
if (marker && typeof controller !== 'string')
throw new VictoryProgressError('A public No-Field needs a valid controller.');
return {
id: player.id, faction: player.faction, ally: player.ally,
reserves: player.reserves,
elites: player.elites ? { reserves: player.elites.reserves } : undefined,
...(marker ? { noField: { deployed: { controller, location: { ...marker.location } } } } : {}),
};
}),
} as StableHomeworldOccupationContext;
for (const world of worlds) {
// Ecaz and its ally's own native worlds are not "other factions".
if (members.includes(world.native)) continue;
const faction = context.players.find(player => player.id === world.native)!.faction;
const card = HOMEWORLD_CARDS.find(card => world.secondary
? card.id === 'salusa_secundus'
: card.faction === faction && card.id !== 'salusa_secundus')!;
const quote = originalHistory
? quoteStableHomeworldOccupation(publicOccupation, card.id)
: { entitlement: null, blocked: 'Ecaz Homeworld holdings require the original fresh occupation history.' };
const present = liveHomeworldOccupationPresence(publicOccupation, world.id);
const alliancePresent = members.some(member => present.includes(member));
if (quote.blocked) {
// Pending facts can block a possible win, but cannot manufacture a
// holding; unrelated occupiers and empty departed worlds cannot block it.
if (alliancePresent) {
possible.add(faction);
blockers.push(quote.blocked);
}
continue;
}
const holding = quote.entitlement;
if (!holding || !members.includes(holding.occupier)) continue;
if (!present.includes(holding.occupier)) continue;
foreignHomeworlds.push({
world: world.id, card: card.id, native: world.native,
faction, holder: holding.occupier,
});
possible.add(faction);
}
const distinctNativeFactions = [...new Set(foreignHomeworlds.map(home => home.faction))];
const high = population.side === 'high';
const jointStrongholds = progress.jointlyOccupied;
const blocked = blockers.length > 0 && possible.size >= 2 && distinctNativeFactions.length < 2
? blockers[0] : null;
return {
owner: ecaz.id, members: [...members], population: population.population,
minimum, high, jointStrongholds: [...jointStrongholds], foreignHomeworlds,
distinctNativeFactions,
qualifies: high && jointStrongholds.length > 0 && distinctNativeFactions.length >= 2,
blocked,
};
} catch (error) {
if (error instanceof VictoryProgressError) throw error;
throw new VictoryProgressError(error instanceof Error ? error.message : 'Invalid public Ecaz Homeworld victory state.');
}
}