import { TERRITORIES } from './board'; import { SPICE_CARDS } from './cards'; import { coalitionOf, CoalitionAllianceError } from './coalition-alliance'; import { presenceAt } from './force-presence'; import type { Game } from './engine'; /** A single worm's group decisions are resolved before it destroys forces. * A separate native Fremen-survival response may follow the complete frame. */ export type CoalitionWormProtectionOutcome = { ally: string; outcome: 'declined' | 'allowed' | 'canceled'; }; export type CoalitionWormProtectionFrame = { event: string; turn: number; territory: string; owner: string; /** Threatened group allies in the original public storm order. */ order: string[]; outcomes: CoalitionWormProtectionOutcome[]; stage: 'decision' | 'response' | 'complete'; }; export class CoalitionWormProtectionError extends Error { constructor(message: string) { super(message); this.name = 'CoalitionWormProtectionError'; } } function requireProtection(value: unknown, message: string): asserts value { if (!value) throw new CoalitionWormProtectionError(message); } const id = (value: unknown): value is string => typeof value === 'string' && value.length > 0 && value.trim() === value; const object = (value: unknown): value is Record => !!value && typeof value === 'object' && !Array.isArray(value); const classic = new Set(['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen']); const validTerritory = (value: string) => TERRITORIES.some(row => row.id === value && (row.type === 'sand' || SPICE_CARDS.some(([territory]) => territory === value))); /** Only the unexposed bare Basic/Advanced classic group profile is supported. * Return null for all legacy pair games and other profiles. */ function threatened(g: Game, owner: string, territory: string): string[] | null { if (!g.coalitionState || g.status !== 'playing' || g.phase !== 1 || g.expansions.length || g.homeworlds || g.nexusCards || g.leaderSkills || g.techTokens || g.strongholdCards || g.ecazTreachery || g.discoveryEnabled || !g.players.every(player => classic.has(player.faction))) return null; const fremen = g.players.find(player => player.id === owner); if (!fremen || fremen.faction !== 'fremen' || g.players.filter(player => player.faction === 'fremen').length !== 1) return null; let group; try { group = coalitionOf(g.players, g.coalitionState, owner); } catch (error) { if (error instanceof CoalitionAllianceError) throw new CoalitionWormProtectionError(error.message); throw error; } if (!group || group.members.length < 3) return null; requireProtection(Array.isArray(g.order) && g.order.length === g.players.length && new Set(g.order).size === g.order.length && g.order.every(seat => g.players.some(player => player.id === seat)), 'Group worm protection requires the current complete storm order.'); return g.order.filter(seat => seat !== owner && group!.members.includes(seat) && presenceAt(g.players.find(player => player.id === seat)!, territory) > 0); } export function createCoalitionWormProtection( g: Game, owner: string, territory: string, event: string, ): CoalitionWormProtectionFrame | null { requireProtection(id(owner) && id(event) && id(territory) && validTerritory(territory) && Number.isSafeInteger(g.turn) && g.turn > 0, 'Choose the original worm occurrence and Fremen owner.'); const order = threatened(g, owner, territory); if (!order?.length) return null; return {event, turn:g.turn, territory, owner, order, outcomes:[], stage:'decision'}; } /** Recheck the captured occurrence and exact threatened order before every * transition; no card, casualty or force transfer is performed here. */ export function validateCoalitionWormProtection(g: Game, frame: CoalitionWormProtectionFrame): void { requireProtection(object(frame) && Object.keys(frame).sort().join(',') === 'event,order,outcomes,owner,stage,territory,turn' && id(frame.event) && id(frame.owner) && id(frame.territory) && validTerritory(frame.territory) && Number.isSafeInteger(frame.turn) && frame.turn === g.turn && Array.isArray(frame.order) && frame.order.length > 0 && frame.order.length <= 5 && frame.order.every(id) && new Set(frame.order).size === frame.order.length && Array.isArray(frame.outcomes) && ['decision', 'response', 'complete'].includes(frame.stage), 'The group worm protection has lost its original occurrence.'); const current = threatened(g, frame.owner, frame.territory); requireProtection(current && JSON.stringify(current) === JSON.stringify(frame.order), 'The original group worm allies or storm order changed.'); requireProtection(frame.outcomes.length <= frame.order.length && frame.outcomes.every((outcome, index) => object(outcome) && Object.keys(outcome).sort().join(',') === 'ally,outcome' && outcome.ally === frame.order[index] && ['declined', 'allowed', 'canceled'].includes(outcome.outcome)) && (frame.stage === 'complete' ? frame.outcomes.length === frame.order.length : frame.outcomes.length < frame.order.length), 'The group worm protection lost its ordered decisions.'); } /** The current ally is public; the decision remains Fremen's. */ export function currentCoalitionWormAlly(g: Game, frame: CoalitionWormProtectionFrame): string | null { validateCoalitionWormProtection(g, frame); return frame.stage === 'complete' ? null : frame.order[frame.outcomes.length]; } export function decideCoalitionWormProtection( g: Game, frame: CoalitionWormProtectionFrame, owner: string, event: string, ally: string, accept: boolean, ): CoalitionWormProtectionFrame { const current = currentCoalitionWormAlly(g, frame); requireProtection(frame.stage === 'decision' && owner === frame.owner && event === frame.event && ally === current && typeof accept === 'boolean', 'Choose the current original Fremen ally decision.'); if (accept) return {...frame, stage:'response'}; const outcomes: CoalitionWormProtectionOutcome[] = [...frame.outcomes, {ally, outcome:'declined'}]; return {...frame, outcomes, stage:outcomes.length === frame.order.length ? 'complete' : 'decision'}; } export function resolveCoalitionWormProtection( g: Game, frame: CoalitionWormProtectionFrame, owner: string, event: string, ally: string, canceled: boolean, ): CoalitionWormProtectionFrame { const current = currentCoalitionWormAlly(g, frame); requireProtection(frame.stage === 'response' && owner === frame.owner && event === frame.event && ally === current && typeof canceled === 'boolean', 'Resolve the original group ally Karama response.'); const outcomes: CoalitionWormProtectionOutcome[] = [...frame.outcomes, {ally, outcome:canceled ? 'canceled' : 'allowed'}]; return {...frame, outcomes, stage:outcomes.length === frame.order.length ? 'complete' : 'decision'}; } export function protectedCoalitionWormAllies(g: Game, frame: CoalitionWormProtectionFrame): string[] { validateCoalitionWormProtection(g, frame); requireProtection(frame.stage === 'complete', 'Finish all original group worm decisions first.'); return frame.outcomes.filter(outcome => outcome.outcome === 'allowed').map(outcome => outcome.ally); }