import type { Game } from './engine'; import { settledBoard, type AdvisorRelease, type BoardContext, } from './board-resolution-quote'; import { ecazOccupancyIdentity } from './ecaz-occupy'; import { gameTerritories } from './board'; import { fighterCount } from './advisors'; import { TECH_TOKENS } from './tech-tokens'; import { isDiscoveryLocationId, JACURUTU_SIETCH } from './discoveries'; import type { TwoPlayerControllers } from './two-player-controllers'; import { allianceVictoryTarget } from './alliance-victory-option'; import { coalitionGroups } from './coalition-alliance'; export type StrongholdProgress = { player: string; members: string[]; strongholds: string[]; techStronghold: boolean; target: number; jointlyOccupied: string[]; occupyTarget: 3 | null; qualifies: boolean; }; export class VictoryProgressError extends Error { constructor(message: string) { super(message); this.name = 'VictoryProgressError'; } } const record = (value: unknown): value is Record => !!value && typeof value === 'object' && !Array.isArray(value); function requireProgress( condition: unknown, message: string, ): asserts condition { if (!condition) throw new VictoryProgressError(message); } /** Ordered side membership for the bounded coalition victory quote. Three-member * Advanced and Ecaz combinations remain unavailable until their board rules * and faction interactions are integrated. No game mode is enabled here. */ export function victoryMembers(g: BoardContext, player: string): string[] { const seat = g.players.find(candidate => candidate.id === player); requireProgress(seat, 'Victory needs a seated player.'); if (g.coalitionState === undefined) return g.order.filter(member => member === player || member === seat.ally); const groups = coalitionGroups(g.players, g.coalitionState); requireProgress(!groups.some(group => group.members.length >= 3 && (g.advanced || g.players.some(candidate => candidate.faction === 'ecaz'))), 'Three-member victory rules require Basic play without Ecaz.'); const group = groups.find(candidate => candidate.members.includes(player)); return g.order.filter(member => member === player || !!group?.members.includes(member)); } /** Discovery locations use stronghold shipping and occupancy rules, but only * Jacurutu Sietch counts toward the normal stronghold victory. */ export function countsForStrongholdVictory(id: string) { return !isDiscoveryLocationId(id) || id === JACURUTU_SIETCH; } /** Public prospective stronghold victory only. No prediction, special endgame * victory, combat resolution, resource mutation or Stronghold Card ownership is * considered. Mandatory advisor releases are returned for the caller to commit. */ export function strongholdProgress( g: BoardContext & Pick & { victoryExtraStrongholds?: number; twoPlayerControllers?: TwoPlayerControllers; shortAllianceVictory?: boolean; }, ): { released: AdvisorRelease[]; progress: StrongholdProgress[] } { try { requireProgress( record(g) && typeof g.advanced === 'boolean' && Array.isArray(g.players) && g.players.length > 0 && g.players.every((p) => record(p) && record(p.forces)), 'Victory progress requires a valid public board and seated players.', ); const extra = g.victoryExtraStrongholds ?? 0; requireProgress(Number.isSafeInteger(extra) && extra >= 0 && extra <= 200, 'The agreed extra stronghold target must be a safe nonnegative count.'); requireProgress(g.shortAllianceVictory === undefined || typeof g.shortAllianceVictory === 'boolean', 'Choose whether the shorter alliance victory option is enabled.'); // This validates every ID, faction and reciprocal alliance, even where no // stronghold has a force. It does not merge ordinary allies for occupancy. ecazOccupancyIdentity(g.players, g.players[0].id, { kind: 'territory', id: 'polar_sink', }); const boardTerritories = gameTerritories(g); for (const p of g.players) if (p.advisors !== undefined) requireProgress( record(p.advisors) && Object.entries(p.advisors).every( ([id, stance]) => boardTerritories.some((t) => t.id === id) && record(stance), ), 'Victory progress requires valid advisor stances.', ); const { players, released } = settledBoard(g); const tech = g.techTokens; if (tech !== undefined && tech !== null) { requireProgress( record(tech) && Object.keys(tech).length === TECH_TOKENS.length && TECH_TOKENS.every( ({ id }) => record(tech[id]) && (tech[id].owner === null || players.some((p) => p.id === tech[id].owner)), ), 'Victory progress requires the complete technology token ownership map.', ); } const sites = boardTerritories.filter( (t) => t.type === 'stronghold' && countsForStrongholdVictory(t.id), ); const progress = g.order.map((id) => { const p = players.find((p) => p.id === id)!; const members = victoryMembers(g, id); const partner = players.find((other) => other.id === p.ally); const ecazPair = !!partner && (p.faction === 'ecaz' || partner.faction === 'ecaz'); const strongholds = sites .filter((t) => { const occupants = players.filter( (other) => fighterCount(other, t.id) > 0, ); return ( occupants.length > 0 && occupants.every((other) => members.includes(other.id)) ); }) .map((t) => t.id); const jointlyOccupied = ecazPair ? strongholds.filter((t) => members.every( (member) => fighterCount( players.find((p) => p.id === member)!, t, ) > 0, ), ) : []; const techStronghold = !!tech && members.some((member) => TECH_TOKENS.every(({ id }) => tech[id].owner === member), ); const target = allianceVictoryTarget( players.map((player) => player.id), g.twoPlayerControllers, members.length > 1, g.coalitionState?.variant === 'unlimited' ? true : members.length >= 3 ? false : g.shortAllianceVictory, ) + extra; return { player: id, members, strongholds, techStronghold, target, jointlyOccupied, occupyTarget: ecazPair ? (3 as const) : null, qualifies: strongholds.length + Number(techStronghold) >= target || jointlyOccupied.length >= 3, }; }); return { released, progress }; } catch (error) { if (error instanceof VictoryProgressError) throw error; throw new VictoryProgressError( error instanceof Error ? error.message : 'Invalid public victory state.', ); } }