import type { FactionId } from './catalog'; import { territory } from './board'; import { fighterCount } from './advisors'; import { presenceAt, type ForcePresence } from './force-presence'; import { ecazOccupancyIdentity, ecazOccupancyRelation } from './ecaz-occupy'; import { coalitionAllies, coalitionGroups, type CoalitionState } from './coalition-alliance'; /** Public board information only: a concealed No-Field contributes presence, * never its hidden denomination. Advisor groups do not fill strongholds. */ export type OccupancySeat = ForcePresence & { id: string; faction: FactionId; ally?: string | null; allySinceTurn?: number; advisors?: Readonly>; }; /** Count the occupying factions after admitting a fighter from enteringId. * Ecaz and its reciprocal ally share a faction for board occupancy. Physical * forces and player ownership remain separate. This is not a combat side. */ export function occupyingFactionCount( players: readonly OccupancySeat[], to: string, enteringId?: string, ) { const where = { kind: 'territory' as const, id: to }; const sides = new Set( players .filter((p) => fighterCount(p, to) > 0) .map((p) => ecazOccupancyIdentity(players, p.id, where).key), ); if (enteringId) sides.add(ecazOccupancyIdentity(players, enteringId, where).key); return sides.size; } export function territoryEntryBlock( players: readonly OccupancySeat[], playerId: string, to: string, advisors = false, temporaryAlliedShipment = false, coalitionState?: CoalitionState, ): string | null { // Validate the complete identity roster even for an empty destination. ecazOccupancyIdentity(players, playerId, { kind: 'territory', id: to }); const player = players.find((p) => p.id === playerId)!; const seats = players.map(p => ({ id: p.id, ally: p.ally ?? null, allySinceTurn: p.allySinceTurn })); if (coalitionState !== undefined) { const groups = coalitionGroups(seats, coalitionState); if (players.some(player => player.faction === 'ecaz') && groups.some(group => group.members.length >= 3)) throw new Error('Three-member board rules do not yet support Ecaz.'); } if (advisors) return null; const allies = coalitionState === undefined ? (player.ally ? [player.ally] : []) : coalitionAllies(seats, coalitionState, playerId); if ( !temporaryAlliedShipment && to !== 'polar_sink' && allies.some(id => { const ally = players.find(p => p.id === id); return !!ally && presenceAt(ally, to) > 0 && ecazOccupancyRelation(players, playerId, id, { kind: 'territory', id: to, }) !== 'ecazAlliance'; }) ) return 'You cannot enter a territory occupied by your ally.'; if ( territory(to).type === 'stronghold' && occupyingFactionCount(players, to, playerId) > 2 ) return 'A stronghold cannot contain three occupying factions.'; return null; } /** Passing through a stronghold uses capacity, not allied destination rules. */ export function strongholdPathBlocked( players: readonly OccupancySeat[], playerId: string, to: string, advisors = false, ) { return ( !advisors && territory(to).type === 'stronghold' && occupyingFactionCount(players, to, playerId) > 2 ); }