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'; export type StrongholdProgress = { player: string; members: string[]; strongholds: string[]; techStronghold: boolean; target: 3 | 4; 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); } /** 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, ): { 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.', ); // 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 = g.order.filter( (member) => member === id || member === p.ally, ); 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: 3 | 4 = partner || players.length === 2 ? 4 : 3; 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.', ); } }