import type { FactionId } from './catalog'; import { splitLocation } from './board'; import { presenceAt, type ForcePresence } from './force-presence'; export type AdvisorStance = { lockedTurn?: number; aloneFlipHold?: string; }; export type AutomaticAdvisorFlipSource = { event: string; owner: string; territory: string; turn: number; phase: number; producer: 'groundMovement'; mover: string; move: number; forces: Record; }; export type AutomaticAdvisorFlipPlayer = ForcePresence & { id: string; faction: FactionId; advisors?: Readonly>; moved?: number; }; export type AdvisorSettlementContext = { advanced: boolean; players: readonly AutomaticAdvisorFlipPlayer[]; }; export type AutomaticAdvisorFlipContext = AdvisorSettlementContext & { status: string; turn: number; phase: number; }; export class AutomaticAdvisorFlipError extends Error { constructor(message: string) { super(message); this.name = 'AutomaticAdvisorFlipError'; } } const identifier = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0; const positive = (value: unknown): value is number => Number.isSafeInteger(value) && (value as number) > 0; const record = (value: unknown): value is Record => !!value && typeof value === 'object' && !Array.isArray(value); function requireFlip(value: unknown, message: string): asserts value { if (!value) throw new AutomaticAdvisorFlipError(message); } function physicalGroup(player: AutomaticAdvisorFlipPlayer, territory: string): Record { return Object.fromEntries(Object.entries(player.forces).filter( ([key, count]) => splitLocation(key).territory === territory && count > 0, )); } function otherPresence(ctx: AdvisorSettlementContext, player: AutomaticAdvisorFlipPlayer, territory: string) { return ctx.players.some((other) => other.id !== player.id && presenceAt(other, territory) > 0); } function currentWindow(ctx: AutomaticAdvisorFlipContext) { return ctx.advanced === true && ctx.status === 'playing' && ctx.phase === 5 && positive(ctx.turn); } /** Only the ground-movement producer opens a response for these current groups. * No cards, resources, leaders or hidden marker values determine eligibility. */ export function quoteAutomaticAdvisorFlips(ctx: AutomaticAdvisorFlipContext): { player: string; territory: string; forces: Record; }[] { if (!currentWindow(ctx)) return []; const flips: { player: string; territory: string; forces: Record }[] = []; for (const player of ctx.players) { if (player.faction !== 'beneGesserit') continue; for (const [territory, stance] of Object.entries(player.advisors ?? {})) { if (stance.aloneFlipHold !== undefined || otherPresence(ctx, player, territory)) continue; const forces = physicalGroup(player, territory); if (Object.keys(forces).length) flips.push({ player: player.id, territory, forces }); } } return flips; } /** Keep a prevented alone occurrence until physical emptiness or foreign entry. * With no occurrence hold, retain the legacy automatic conversion, including setup. */ export function advisorStanceAfterSettlement( ctx: AdvisorSettlementContext, player: AutomaticAdvisorFlipPlayer | string, territory: string, ): AdvisorStance | undefined { const current = ctx.players.find((candidate) => candidate.id === (typeof player === 'string' ? player : player.id)); const stance = current?.advisors?.[territory]; if (!current || current.faction !== 'beneGesserit' || !stance) return undefined; if (!ctx.advanced) return { ...stance }; if (!Object.keys(physicalGroup(current, territory)).length) return undefined; if (otherPresence(ctx, current, territory)) { const remaining = { ...stance }; delete remaining.aloneFlipHold; return remaining; } return stance.aloneFlipHold !== undefined ? { ...stance } : undefined; } /** Validate the same committed move and exact held group before either response outcome. */ export function validateAutomaticAdvisorFlip( ctx: AutomaticAdvisorFlipContext, source: AutomaticAdvisorFlipSource, ): void { requireFlip(record(source) && Object.keys(source).sort().join(',') === 'event,forces,move,mover,owner,phase,producer,territory,turn' && identifier(source.event) && identifier(source.owner) && identifier(source.territory) && identifier(source.mover) && source.producer === 'groundMovement' && positive(source.turn) && source.turn === ctx.turn && source.phase === 5 && currentWindow(ctx) && source.phase === ctx.phase, 'Automatic advisor conversion lost its original current-turn ground movement.'); const owners = ctx.players.filter((player) => player.id === source.owner); const owner = owners[0]; requireFlip(owners.length === 1 && owner?.faction === 'beneGesserit' && owner.advisors?.[source.territory]?.aloneFlipHold === source.event, 'Automatic advisor conversion needs its original native owner and held occurrence.'); requireFlip(!otherPresence(ctx, owner, source.territory), 'Automatic advisor conversion requires the original group to remain alone.'); const movers = ctx.players.filter((player) => player.id === source.mover); requireFlip(movers.length === 1 && positive(source.move) && movers[0].moved === source.move, 'Automatic advisor conversion lost its original completed movement.'); const forces = physicalGroup(owner, source.territory); requireFlip(record(source.forces) && Object.keys(forces).length > 0 && Object.values(forces).every(positive) && Object.values(source.forces).every(positive) && Object.keys(forces).sort().join(',') === Object.keys(source.forces).sort().join(',') && Object.entries(forces).every(([key, count]) => source.forces[key] === count), 'Automatic advisor conversion needs the same complete physical advisor group.'); }