import { MOBILE_STRONGHOLD, splitLocation } from './board'; import { ecazOccupancyRelation } from './ecaz-occupy'; import { presenceAt, type ForcePresence } from './force-presence'; import type { OccupancySeat } from './occupancy'; import { coalitionAllies, coalitionGroups, type CoalitionState } from './coalition-alliance'; import { FACTIONS } from './catalog'; /** Physical baseline distinguishes this turn's visitors from old co-occupation. */ export type BasicAlliedShipment = Readonly<{ player: string; ally: string; /** Present allies at arrival for a three-member group; absent in legacy pair receipts. */ allies?: readonly string[]; turn: number; territory: string; baseline: Readonly<{ normal: number; elite: number }>; /** Private token identity for a newly shipped concealed visitor. */ noField?: string; }>; type ShipmentContext = Readonly<{ phase: number; active: string | null; players: readonly OccupancySeat[]; advanced?: boolean; coalitionState?: CoalitionState; }>; type ShipmentPlayer = OccupancySeat & Readonly<{ shipped?: boolean; moved?: number; elites?: { forces: Readonly> }; }>; function currentShipmentAllies(context: ShipmentContext, player: OccupancySeat): string[] { if (context.coalitionState === undefined) { const ally = context.players.find(seat => seat.id === player.ally); return ally?.ally === player.id ? [ally.id] : []; } const seats = context.players.map(seat => ({ id: seat.id, ally: seat.ally ?? null, allySinceTurn: seat.allySinceTurn, })); const groups = coalitionGroups(seats, context.coalitionState); if (groups.some(group => group.members.length >= 3) && (context.players.some(seat => seat.faction === 'ecaz') || context.advanced && context.players.some(seat => !FACTIONS.some(faction => faction.id === seat.faction && faction.expansion === 'base')))) throw new Error('Larger alliance shipment rules require a supported roster without Ecaz.'); return coalitionAllies(seats, context.coalitionState, player.id); } function presentShipmentAllies(context: ShipmentContext, player: OccupancySeat, to: string, allies: readonly string[]): string[] { return allies.filter(id => { const ally = context.players.find(seat => seat.id === id); return !!ally && presenceAt(ally, to) > 0 && ecazOccupancyRelation(context.players, player.id, ally.id, { kind: 'territory', id: to, }) !== 'ecazAlliance'; }); } /** Ordinary reserve arrivals only; movement endpoints keep their guard. */ export function isAlliedShipmentVisit( context: ShipmentContext, player: ShipmentPlayer, to: string, ): boolean { return alliedShipmentWitnesses(context, player, to).length > 0; } /** Eligible allies currently at a temporary shipment destination. */ export function alliedShipmentWitnesses(context: ShipmentContext, player: ShipmentPlayer, to: string): string[] { const allies = currentShipmentAllies(context, player); if (context.phase !== 5 || context.active !== player.id || player.shipped !== false || player.moved !== 0 || to === 'polar_sink' || to === MOBILE_STRONGHOLD) return []; return presentShipmentAllies(context, player, to, allies); } export function alliedShipmentPhysicalPool( player: ForcePresence & { elites?: { forces: Readonly> } }, to: string, ): { normal: number; elite: number } { let normal = 0; let elite = 0; for (const key in player.forces) { if (splitLocation(key).territory !== to) continue; const special = player.elites?.forces[key] ?? 0; elite += special; normal += player.forces[key] - special; } return { normal, elite }; } /** Capture one physical baseline even when several allies are present. */ export function makeBasicAlliedShipment( context: ShipmentContext & Readonly<{ turn: number }>, player: ShipmentPlayer, to: string, ): BasicAlliedShipment | null { const witnesses = alliedShipmentWitnesses(context, player, to); if (!witnesses.length) return null; const allies = currentShipmentAllies(context, player); return { player: player.id, ally: witnesses[0], turn: context.turn, territory: to, baseline: alliedShipmentPhysicalPool(player, to), ...(allies.length > 1 ? { allies: witnesses } : {}) }; } /** A live witness is also needed for a concealed zero-force marker. */ export function basicAlliedShipmentHasWitness( context: ShipmentContext & Readonly<{ turn: number }>, player: ShipmentPlayer, visit: BasicAlliedShipment | null | undefined, ): boolean { const allies = currentShipmentAllies(context, player); if (!visit || context.advanced || context.phase !== 5 || visit.player !== player.id || visit.turn !== context.turn) return false; if (visit.allies !== undefined) { if (context.coalitionState === undefined || !Array.isArray(visit.allies) || !visit.allies.length || visit.allies.some(id => typeof id !== 'string' || !id || id === player.id) || new Set(visit.allies).size !== visit.allies.length || visit.ally !== visit.allies[0]) throw new Error('Invalid three-member allied shipment receipt.'); } // A legacy receipt continues to refer to its original reciprocal partner. // For group receipts the original witnesses may leave while another current // ally remains, so consider the complete current membership instead. const candidates = visit.allies === undefined ? allies.filter(id => id === visit.ally) : allies; return presentShipmentAllies(context, player, visit.territory, candidates).length > 0; } /** Quote physical visitors remaining alongside a current ally; territory is an ID. */ export function quoteBasicAlliedShipment( context: ShipmentContext & Readonly<{ turn: number }>, player: ShipmentPlayer, visit: BasicAlliedShipment | null | undefined, ): { territory: string; normal: number; elite: number } | null { if (!visit || !basicAlliedShipmentHasWitness(context, player, visit)) return null; const pool = alliedShipmentPhysicalPool(player, visit.territory); const normal = Math.max(0, pool.normal - visit.baseline.normal); const elite = Math.max(0, pool.elite - visit.baseline.elite); return normal + elite ? { territory: visit.territory, normal, elite } : null; } export type AlliedSeparationQuoteContext = Readonly<{ advanced: boolean; turn: number; player: string; ally: string | null; playerAllySinceTurn?: number; allySinceTurn?: number; remaining: readonly string[]; territories: readonly Readonly<{ territory: string; polar: boolean; ownPresent: boolean; allyPresent: boolean; ownAdvisors: boolean; allyAdvisors: boolean; ecazCoexist: boolean; }>[]; }>; /** * Quote the ending player's loss territories, not casualties or force custody. * The engine supplies canonical presence, advisor stance and Ecaz coexistence. * Advanced separates at every player's end; Basic retains its legacy timing. */ export function quoteAlliedSeparation( context: AlliedSeparationQuoteContext, ): string[] { if (!context.ally) return []; if ( !context.advanced && (context.remaining.includes(context.ally) || (context.playerAllySinceTurn === context.turn && context.allySinceTurn === context.turn)) ) return []; const territories: string[] = []; for (const group of context.territories) { if ( group.polar || !group.ownPresent || !group.allyPresent || group.ecazCoexist || (context.advanced && (group.ownAdvisors || group.allyAdvisors)) ) continue; territories.push(group.territory); } return territories; }